diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index c4ba6e5143..6ec071be2f 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,7 +9,7 @@ ] }, "nvika": { - "version": "3.0.0", + "version": "4.0.0", "commands": [ "nvika" ] diff --git a/.editorconfig b/.editorconfig index 7aecde95ee..a145efc348 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,6 +19,11 @@ indent_style = space indent_size = 4 trim_trailing_whitespace = true +# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130051/Cannot-resolve-symbol-inspections-incorrectly-firing-for-xmldoc-protected-member-references +resharper_c_sharp_warnings_cs1574_cs1584_cs1581_cs1580_highlighting = hint +# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130381/Rider-does-not-respect-propagated-NoWarn-CS1591?backToIssues=false +dotnet_diagnostic.CS1591.severity = none + #license header file_header_template = Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text. diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml index 4e221d0550..2f1b2cf893 100644 --- a/.github/workflows/_diffcalc_processor.yml +++ b/.github/workflows/_diffcalc_processor.yml @@ -36,7 +36,7 @@ jobs: generator: name: Run runs-on: self-hosted - timeout-minutes: 720 + timeout-minutes: 1440 outputs: target: ${{ steps.run.outputs.target }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8645d728e..7dfe3d11c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,8 +82,18 @@ jobs: run: dotnet build -c Debug -warnaserror osu.Desktop.slnf - name: Test - run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" -- NUnit.ConsoleOut=0 - shell: pwsh + run: > + dotnet test + osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll + osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll + osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll + osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll + osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll + osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll + Templates/**/*.Tests/bin/Debug/**/*.Tests.dll + --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" + -- + NUnit.ConsoleOut=0 # Attempt to upload results even if test fails. # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always @@ -114,17 +124,14 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET workloads - # since windows image 20241113.3.0, not specifying a version here - # installs the .NET 7 version of android workload for very unknown reasons. - # revisit once we upgrade to .NET 9, it's probably fixed there. - run: dotnet workload install android --version (dotnet --version) + run: dotnet workload install android - name: Compile run: dotnet build -c Debug osu.Android.slnf build-only-ios: name: Build only (iOS) - runs-on: macos-latest + runs-on: macos-15 timeout-minutes: 60 steps: - name: Checkout @@ -136,7 +143,12 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET Workloads - run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json + run: dotnet workload install ios + + # https://github.com/dotnet/macios/issues/19157 + # https://github.com/actions/runner-images/issues/12758 + - name: Use Xcode 16.4 + run: sudo xcode-select -switch /Applications/Xcode_16.4.app - name: Build - run: dotnet build -c Debug osu.iOS + run: dotnet build -c Debug osu.iOS.slnf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..1a921b21ae --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,87 @@ +name: Pack and nuget + +on: + push: + tags: + - '*' + +jobs: + notify_pending_production_deploy: + runs-on: ubuntu-latest + steps: + - name: Submit pending deployment notification + run: | + export TITLE="Pending osu Production Deployment: $GITHUB_REF_NAME" + export URL="https://github.com/ppy/osu/actions/runs/$GITHUB_RUN_ID" + export DESCRIPTION="Awaiting approval for building NuGet packages for tag $GITHUB_REF_NAME: + [View Workflow Run]($URL)" + export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID" + + BODY="$(jq --null-input '{ + "embeds": [ + { + "title": env.TITLE, + "color": 15098112, + "description": env.DESCRIPTION, + "url": env.URL, + "author": { + "name": env.GITHUB_ACTOR, + "icon_url": env.ACTOR_ICON + } + } + ] + }')" + + curl \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}" + + pack: + name: Pack + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set artifacts directory + id: artifactsPath + run: echo "::set-output name=nuget_artifacts::${{github.workspace}}/artifacts" + + - name: Install .NET 8.0.x + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Pack + run: | + # Replace project references in templates with package reference, because they're included as source files. + dotnet remove Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game/osu.Game.csproj + dotnet remove Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj + dotnet remove Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game/osu.Game.csproj + dotnet remove Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj + + dotnet add Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} + dotnet add Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} + dotnet add Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} + dotnet add Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} + + # Pack + dotnet pack -c Release osu.Game /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} + dotnet pack -c Release osu.Game.Rulesets.Osu /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} + dotnet pack -c Release osu.Game.Rulesets.Taiko /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} + dotnet pack -c Release osu.Game.Rulesets.Catch /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} + dotnet pack -c Release osu.Game.Rulesets.Mania /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} + dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: osu + path: | + ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg + ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.snupkg + + - name: Publish packages to nuget.org + run: dotnet nuget push ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index 4297a88e89..8461208a2e 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -115,7 +115,7 @@ jobs: steps: - name: Check permissions run: | - ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte) + ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders) for i in "${ALLOWED_USERS[@]}"; do if [[ "${{ github.actor }}" == "$i" ]]; then exit 0 diff --git a/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000000..4432459b86 --- /dev/null +++ b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebe1e08074..1d9861baf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,9 @@ Aside from the above, below is a brief checklist of things to watch out when you After you're done with your changes and you wish to open the PR, please observe the following recommendations: - Please submit the pull request from a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary. +- Please pick the following target branch for your pull request: + - `pp-dev`, if the change impacts star rating or performance points calculations for any of the rulesets, + - `master`, otherwise. - Please avoid pushing untested or incomplete code. - Please do not force-push or rebase unless we ask you to. - Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change is ready for merge. diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 550f7c8e11..58f281a01d 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -18,3 +18,10 @@ M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize( M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead. M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead. M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead. +M:osuTK.MathHelper.Clamp(System.Int32,System.Int32,System.Int32)~System.Int32;Use Math.Clamp() instead. +M:osuTK.MathHelper.Clamp(System.Single,System.Single,System.Single)~System.Single;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead. +M:osuTK.MathHelper.Clamp(System.Double,System.Double,System.Double)~System.Double;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead. +M:TagLib.File.Create(System.String);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead. +M:TagLib.File.Create(TagLib.File.IFileAbstraction);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead. +M:TagLib.File.Create(System.String,TagLib.ReadStyle);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead. +M:TagLib.File.Create(TagLib.File.IFileAbstraction,TagLib.ReadStyle);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead. diff --git a/CodeAnalysis/osu.globalconfig b/CodeAnalysis/osu.globalconfig index 247a825033..8012c31eca 100644 --- a/CodeAnalysis/osu.globalconfig +++ b/CodeAnalysis/osu.globalconfig @@ -51,8 +51,11 @@ dotnet_diagnostic.IDE1006.severity = warning # Too many noisy warnings for parsing/formatting numbers dotnet_diagnostic.CA1305.severity = none +# messagepack complains about "osu" not being title cased due to reserved words +dotnet_diagnostic.CS8981.severity = none + # CA1507: Use nameof to express symbol names -# Flaggs serialization name attributes +# Flags serialization name attributes dotnet_diagnostic.CA1507.severity = suggestion # CA1806: Do not ignore method results diff --git a/Directory.Build.props b/Directory.Build.props index 3acb86ee0c..a856825d87 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,10 @@ 12.0 enable + + false + + $(NoWarn);CA1416 $(MSBuildThisFileDirectory)app.manifest @@ -46,7 +50,7 @@ https://github.com/ppy/osu Automated release. ppy Pty Ltd - Copyright (c) 2024 ppy Pty Ltd + Copyright (c) 2025 ppy Pty Ltd osu game diff --git a/LICENCE b/LICENCE index 3bb8b62d5d..9ffcc70c13 100644 --- a/LICENCE +++ b/LICENCE @@ -1,4 +1,4 @@ -Copyright (c) 2024 ppy Pty Ltd . +Copyright (c) 2025 ppy Pty Ltd . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6043497181..d87ca31f72 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ You can also generally download a version for your current device from the [osu! If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below. -**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024. +**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation. ## Developing a custom ruleset 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 f77cda1533..86f73a37d4 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 @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs index 9cd18d2d9f..0699f5d039 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs @@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.EmptyFreeform.Objects public Vector2 Position { get; set; } - public float X => Position.X; - public float Y => Position.Y; + public float X + { + get => Position.X; + set => Position = new Vector2(value, Y); + } + + public float Y + { + get => Position.Y; + set => Position = new Vector2(X, value); + } } } diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs index c84101ca70..c6be5d6861 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs @@ -2,6 +2,7 @@ // 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 osuTK; @@ -17,5 +18,8 @@ namespace osu.Game.Rulesets.EmptyFreeform.Replays if (button.HasValue) Actions.Add(button.Value); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is EmptyFreeformReplayFrame freeformFrame && Time == freeformFrame.Time && Position == freeformFrame.Position && Actions.SequenceEqual(freeformFrame.Actions); } } 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 47cabaddb1..51c0233942 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 @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs index 0c22554e82..f938d26b26 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs @@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.Pippidon.Objects public Vector2 Position { get; set; } - public float X => Position.X; - public float Y => Position.Y; + public float X + { + get => Position.X; + set => Position = new Vector2(value, Y); + } + + public float Y + { + get => Position.Y; + set => Position = new Vector2(X, value); + } } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs index 949ca160be..c434b62257 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs @@ -9,5 +9,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays public class PippidonReplayFrame : ReplayFrame { public Vector2 Position; + + public override bool IsEquivalentTo(ReplayFrame other) + => other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Position == pippidonFrame.Position; } } 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 a7d62291d0..ed4e8631ea 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 @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs index 2f19cffd2a..722eff6f05 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Replays; namespace osu.Game.Rulesets.EmptyScrolling.Replays @@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.EmptyScrolling.Replays if (button.HasValue) Actions.Add(button.Value); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is EmptyScrollingReplayFrame scrollingFrame && Time == scrollingFrame.Time && Actions.SequenceEqual(scrollingFrame.Actions); } } 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 47cabaddb1..51c0233942 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 @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs index 0a4fa84ce1..dd8337abee 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs @@ -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 System.Threading; @@ -9,7 +10,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Pippidon.Objects; using osu.Game.Rulesets.Pippidon.UI; -using osuTK; namespace osu.Game.Rulesets.Pippidon.Beatmaps { @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Pippidon.Beatmaps }; } - private int getLane(HitObject hitObject) => (int)MathHelper.Clamp( + private int getLane(HitObject hitObject) => (int)Math.Clamp( (getUsablePosition(hitObject) - minPosition) / (maxPosition - minPosition) * PippidonPlayfield.LANE_COUNT, 0, PippidonPlayfield.LANE_COUNT - 1); private float getUsablePosition(HitObject h) => (h as IHasYPosition)?.Y ?? ((IHasXPosition)h).X; diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs index 468ac9c725..c8df06f6d7 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Replays; namespace osu.Game.Rulesets.Pippidon.Replays @@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays if (button.HasValue) Actions.Add(button.Value); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Actions.SequenceEqual(pippidonFrame.Actions); } } diff --git a/Templates/osu.Game.Templates.csproj b/Templates/osu.Game.Templates.csproj index 186a6093f5..ecac2e4794 100644 --- a/Templates/osu.Game.Templates.csproj +++ b/Templates/osu.Game.Templates.csproj @@ -8,7 +8,7 @@ https://github.com/ppy/osu/blob/master/Templates https://github.com/ppy/osu Automated release. - Copyright (c) 2024 ppy Pty Ltd + Copyright (c) 2025 ppy Pty Ltd Templates to use when creating a ruleset for consumption in osu!. dotnet-new;templates;osu netstandard2.1 diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index ed48a997e8..0000000000 --- a/appveyor.yml +++ /dev/null @@ -1,32 +0,0 @@ -clone_depth: 1 -version: '{branch}-{build}' -image: Visual Studio 2022 -cache: - - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml' - -dotnet_csproj: - patch: true - file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects - version: '0.0.{build}' - -before_build: - - cmd: dotnet --info # Useful when version mismatch between CI and local - - cmd: dotnet workload install maui-android # Change to `dotnet workload restore` once there's no old projects - - cmd: dotnet workload install maui-ios # Change to `dotnet workload restore` once there's no old projects - - cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects - -build: - project: osu.sln - parallel: true - verbosity: minimal - publish_nuget: true - -after_build: - - ps: .\InspectCode.ps1 - -test: - assemblies: - except: - - '**\*Android*' - - '**\*iOS*' - - 'build\**\*' diff --git a/appveyor_deploy.yml b/appveyor_deploy.yml deleted file mode 100644 index 175c8d0f1b..0000000000 --- a/appveyor_deploy.yml +++ /dev/null @@ -1,86 +0,0 @@ -clone_depth: 1 -version: '{build}' -image: Visual Studio 2022 -test: off -skip_non_tags: true -configuration: Release - -environment: - matrix: - - job_name: osu-game - - job_name: osu-ruleset - job_depends_on: osu-game - - job_name: taiko-ruleset - job_depends_on: osu-game - - job_name: catch-ruleset - job_depends_on: osu-game - - job_name: mania-ruleset - job_depends_on: osu-game - - job_name: templates - job_depends_on: osu-game - -nuget: - project_feed: true - -for: - - - matrix: - only: - - job_name: osu-game - build_script: - - cmd: dotnet pack osu.Game\osu.Game.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% - - - matrix: - only: - - job_name: osu-ruleset - build_script: - - cmd: dotnet remove osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj reference osu.Game\osu.Game.csproj - - cmd: dotnet add osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - cmd: dotnet pack osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% - - - matrix: - only: - - job_name: taiko-ruleset - build_script: - - cmd: dotnet remove osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj reference osu.Game\osu.Game.csproj - - cmd: dotnet add osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - cmd: dotnet pack osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% - - - matrix: - only: - - job_name: catch-ruleset - build_script: - - cmd: dotnet remove osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj reference osu.Game\osu.Game.csproj - - cmd: dotnet add osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - cmd: dotnet pack osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% - - - matrix: - only: - - job_name: mania-ruleset - build_script: - - cmd: dotnet remove osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj reference osu.Game\osu.Game.csproj - - cmd: dotnet add osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - cmd: dotnet pack osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% - - - matrix: - only: - - job_name: templates - build_script: - - cmd: dotnet remove Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game\osu.Game.csproj - - cmd: dotnet remove Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj - - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game\osu.Game.csproj - - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj - - - cmd: dotnet add Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - cmd: dotnet add Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - - - cmd: dotnet pack Templates\osu.Game.Templates.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% - -artifacts: - - path: '**\*.nupkg' - -deploy: - - provider: Environment - name: nuget diff --git a/osu.Android.props b/osu.Android.props index 632325725a..2df686d354 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + Release Difference / ms + // release_threshold + if (isOverlapping) + holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); + + return (1 + holdAddition) * holdFactor; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index db60e757e1..512d98f713 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -10,22 +9,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty { public class ManiaDifficultyAttributes : DifficultyAttributes { - /// - /// The hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc). - /// - /// - /// Rate-adjusting mods do not affect the hit window at all in osu-stable. - /// - [JsonProperty("great_hit_window")] - public double GreatHitWindow { get; set; } - public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() { foreach (var v in base.ToDatabaseAttributes()) yield return v; yield return (ATTRIB_ID_DIFFICULTY, StarRating); - yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -33,7 +22,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty base.FromDatabaseAttributes(values, onlineInfo); StarRating = values[ATTRIB_ID_DIFFICULTY]; - GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index ff9aa4aa7b..bcf16e6808 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty private const double difficulty_multiplier = 0.018; private readonly bool isForCurrentRuleset; - private readonly double originalOverallDifficulty; public override int Version => 20241007; @@ -35,7 +34,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty : base(ruleset, beatmap) { isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); - originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -48,11 +46,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes { - StarRating = skills[0].DifficultyValue() * difficulty_multiplier, + StarRating = skills.OfType().Single().DifficultyValue() * difficulty_multiplier, Mods = mods, - // In osu-stable mania, rate-adjustment mods don't affect the hit window. - // This is done the way it is to introduce fractional differences in order to match osu-stable for the time being. - GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate), MaxCombo = beatmap.HitObjects.Sum(maxComboForObject), }; @@ -70,13 +65,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { var sortedObjects = beatmap.HitObjects.ToArray(); + int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns; LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); List objects = new List(); + List[] perColumnObjects = new List[totalColumns]; + + for (int column = 0; column < totalColumns; column++) + perColumnObjects[column] = new List(); for (int i = 1; i < sortedObjects.Length; i++) - objects.Add(new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, objects.Count)); + { + var currentObject = new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, perColumnObjects, objects.Count); + objects.Add(currentObject); + perColumnObjects[currentObject.Column].Add(currentObject); + } return objects; } @@ -124,29 +128,5 @@ namespace osu.Game.Rulesets.Mania.Difficulty }).ToArray(); } } - - private double getHitWindow300(Mod[] mods) - { - if (isForCurrentRuleset) - { - double od = Math.Min(10.0, Math.Max(0, 10.0 - originalOverallDifficulty)); - return applyModAdjustments(34 + 3 * od, mods); - } - - if (Math.Round(originalOverallDifficulty) > 4) - return applyModAdjustments(34, mods); - - return applyModAdjustments(47, mods); - - static double applyModAdjustments(double value, Mod[] mods) - { - if (mods.Any(m => m is ManiaModHardRock)) - value /= 1.4; - else if (mods.Any(m => m is ManiaModEasy)) - value *= 1.4; - - return value; - } - } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs index a67d38b29f..91b6a2b861 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs @@ -12,9 +12,59 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing { public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject; - public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) + private readonly List[] perColumnObjects; + + private readonly int columnIndex; + + public readonly int Column; + + // The hit object earlier in time than this note in each column + public readonly ManiaDifficultyHitObject?[] PreviousHitObjects; + + public readonly double ColumnStrainTime; + + public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, List[] perColumnObjects, int index) : base(hitObject, lastObject, clockRate, objects, index) { + int totalColumns = perColumnObjects.Length; + this.perColumnObjects = perColumnObjects; + Column = BaseObject.Column; + columnIndex = perColumnObjects[Column].Count; + PreviousHitObjects = new ManiaDifficultyHitObject[totalColumns]; + ColumnStrainTime = StartTime - PrevInColumn(0)?.StartTime ?? StartTime; + + if (index > 0) + { + ManiaDifficultyHitObject prevNote = (ManiaDifficultyHitObject)objects[index - 1]; + + for (int i = 0; i < prevNote.PreviousHitObjects.Length; i++) + PreviousHitObjects[i] = prevNote.PreviousHitObjects[i]; + + // intentionally depends on processing order to match live. + PreviousHitObjects[prevNote.Column] = prevNote; + } + } + + /// + /// The previous object in the same column as this , exclusive of Long Note tails. + /// + /// The number of notes to go back. + /// The object in this column notes back, or null if this is the first note in the column. + public ManiaDifficultyHitObject? PrevInColumn(int backwardsIndex) + { + int index = columnIndex - (backwardsIndex + 1); + return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null; + } + + /// + /// The next object in the same column as this , exclusive of Long Note tails. + /// + /// The number of notes to go forward. + /// The object in this column notes forward, or null if this is the last note in the column. + public ManiaDifficultyHitObject? NextInColumn(int forwardsIndex) + { + int index = columnIndex + (forwardsIndex + 1); + return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null; } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index bb4261ea13..037b7e3511 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Utils; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mania.Difficulty.Evaluators; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; @@ -15,23 +14,17 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills { private const double individual_decay_base = 0.125; private const double overall_decay_base = 0.30; - private const double release_threshold = 30; protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 1; - private readonly double[] startTimes; - private readonly double[] endTimes; private readonly double[] individualStrains; - - private double individualStrain; + private double highestIndividualStrain; private double overallStrain; public Strain(Mod[] mods, int totalColumns) : base(mods) { - startTimes = new double[totalColumns]; - endTimes = new double[totalColumns]; individualStrains = new double[totalColumns]; overallStrain = 1; } @@ -39,65 +32,24 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { var maniaCurrent = (ManiaDifficultyHitObject)current; - double startTime = maniaCurrent.StartTime; - double endTime = maniaCurrent.EndTime; - int column = maniaCurrent.BaseObject.Column; - bool isOverlapping = false; - double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information - double holdFactor = 1.0; // Factor to all additional strains in case something else is held - double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + individualStrains[maniaCurrent.Column] = applyDecay(individualStrains[maniaCurrent.Column], maniaCurrent.ColumnStrainTime, individual_decay_base); + individualStrains[maniaCurrent.Column] += IndividualStrainEvaluator.EvaluateDifficultyOf(current); - for (int i = 0; i < endTimes.Length; ++i) - { - // The current note is overlapped if a previous note or end is overlapping the current note body - isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && - Precision.DefinitelyBigger(endTime, endTimes[i], 1) && - Precision.DefinitelyBigger(startTime, startTimes[i], 1); + // Take the hardest individualStrain for notes that happen at the same time (in a chord). + // This is to ensure the order in which the notes are processed does not affect the resultant total strain. + highestIndividualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(highestIndividualStrain, individualStrains[maniaCurrent.Column]) : individualStrains[maniaCurrent.Column]; - // We give a slight bonus to everything if something is held meanwhile - if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) && - Precision.DefinitelyBigger(startTime, startTimes[i], 1)) - holdFactor = 1.25; - - closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i])); - } - - // The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending. - // Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. - // holdAddition - // ^ - // 1.0 + - - - - - -+----------- - // | / - // 0.5 + - - - - -/ Sigmoid Curve - // | /| - // 0.0 +--------+-+---------------> Release Difference / ms - // release_threshold - if (isOverlapping) - holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); - - // Decay and increase individualStrains in own column - individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base); - individualStrains[column] += 2.0 * holdFactor; - - // For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns - individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column]; - - // Decay and increase overallStrain - overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base); - overallStrain += (1 + holdAddition) * holdFactor; - - // Update startTimes and endTimes arrays - startTimes[column] = startTime; - endTimes[column] = endTime; + overallStrain = applyDecay(overallStrain, maniaCurrent.DeltaTime, overall_decay_base); + overallStrain += OverallStrainEvaluator.EvaluateDifficultyOf(current); // By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. - return individualStrain + overallStrain - CurrentStrain; + return highestIndividualStrain + overallStrain - CurrentStrain; } - protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) - => applyDecay(individualStrain, offset - current.Previous(0).StartTime, individual_decay_base) - + applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base); + protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) => + applyDecay(highestIndividualStrain, offset - current.Previous(0).StartTime, individual_decay_base) + + applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base); private double applyDecay(double value, double deltaTime, double decayBase) => value * Math.Pow(decayBase, deltaTime / 1000); diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 13cfc5f691..094c59da46 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -98,9 +98,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints private double originalStartTime; - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { - base.UpdateTimeAndPosition(result); + var result = base.UpdateTimeAndPosition(screenSpacePosition, fallbackTime); if (PlacementActive == PlacementState.Active) { @@ -121,6 +121,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints if (result.Time is double startTime) originalStartTime = HitObject.StartTime = startTime; } + + return result; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index 915706c044..ff29154f87 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; @@ -24,7 +23,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints private EditorBeatmap? editorBeatmap { get; set; } [Resolved] - private IPositionSnapProvider? positionSnapProvider { get; set; } + private ManiaHitObjectComposer? positionSnapProvider { get; set; } private EditBodyPiece body = null!; private EditHoldNoteEndPiece head = null!; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index a68bd5d6d6..423f14b092 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.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. -#nullable disable - +using System; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; @@ -20,13 +20,18 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { protected new T HitObject => (T)base.HitObject; - private Column column; + [Resolved] + private ManiaHitObjectComposer? composer { get; set; } - public Column Column + private Column? column; + + public Column? Column { get => column; set { + ArgumentNullException.ThrowIfNull(value); + if (value == column) return; @@ -53,9 +58,11 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints return true; } - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { - base.UpdateTimeAndPosition(result); + var result = composer?.FindSnappedPositionAndTime(screenSpacePosition) ?? new SnapResult(screenSpacePosition, fallbackTime); + + base.UpdateTimeAndPosition(result.ScreenSpacePosition, result.Time ?? fallbackTime); if (result.Playfield is Column col) { @@ -76,6 +83,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints if (PlacementActive == PlacementState.Waiting) Column = col; } + + return result; } private float getNoteHeight(Column resultPlayfield) => diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs index 422215db57..a8cccfb067 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs @@ -8,6 +8,7 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; +using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints @@ -35,15 +36,17 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints }; } - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double referenceTime) { - base.UpdateTimeAndPosition(result); + var result = base.UpdateTimeAndPosition(screenSpacePosition, referenceTime); if (result.Playfield != null) { piece.Width = result.Playfield.DrawWidth; piece.Position = ToLocalSpace(result.ScreenSpacePosition); } + + return result; } protected override bool OnMouseDown(MouseDownEvent e) diff --git a/osu.Game.Rulesets.Mania/Edit/Checks/CheckKeyCount.cs b/osu.Game.Rulesets.Mania/Edit/Checks/CheckKeyCount.cs index 51ead5f423..09394b2046 100644 --- a/osu.Game.Rulesets.Mania/Edit/Checks/CheckKeyCount.cs +++ b/osu.Game.Rulesets.Mania/Edit/Checks/CheckKeyCount.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var diff = context.Beatmap.Difficulty; + var diff = context.CurrentDifficulty.Playable.Difficulty; if (diff.CircleSize < 4) { diff --git a/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaAbnormalDifficultySettings.cs b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaAbnormalDifficultySettings.cs index 233c602c21..cdea7c88a7 100644 --- a/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaAbnormalDifficultySettings.cs +++ b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaAbnormalDifficultySettings.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Checks public override IEnumerable Run(BeatmapVerifierContext context) { - var diff = context.Beatmap.Difficulty; + var diff = context.CurrentDifficulty.Playable.Difficulty; Issue? issue; if (HasMoreThanOneDecimalPlace("Overall difficulty", diff.OverallDifficulty, out issue)) diff --git a/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaConcurrentObjects.cs b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaConcurrentObjects.cs new file mode 100644 index 0000000000..4cf44e27ac --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaConcurrentObjects.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; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Mania.Edit.Checks +{ + public class CheckManiaConcurrentObjects : CheckConcurrentObjects + { + public override IEnumerable Run(BeatmapVerifierContext context) + { + var hitObjects = context.CurrentDifficulty.Playable.HitObjects; + + for (int i = 0; i < hitObjects.Count - 1; ++i) + { + var hitobject = hitObjects[i]; + + for (int j = i + 1; j < hitObjects.Count; ++j) + { + var nextHitobject = hitObjects[j]; + + // Mania hitobjects are only considered concurrent if they also share the same column. + if ((hitobject as IHasColumn)?.Column != (nextHitobject as IHasColumn)?.Column) + continue; + + // Two hitobjects cannot be concurrent without also being concurrent with all objects in between. + // So if the next object is not concurrent or almost concurrent, then we know no future objects will be either. + if (!AreConcurrent(hitobject, nextHitobject) && !AreAlmostConcurrent(hitobject, nextHitobject)) + break; + + if (AreConcurrent(hitobject, nextHitobject)) + { + yield return new IssueTemplateConcurrent(this).Create(hitobject, nextHitobject); + } + else if (AreAlmostConcurrent(hitobject, nextHitobject)) + { + yield return new IssueTemplateAlmostConcurrent(this).Create(hitobject, nextHitobject); + } + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs new file mode 100644 index 0000000000..5e2223467d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.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 System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; + +namespace osu.Game.Rulesets.Mania.Edit.Checks +{ + public class CheckManiaLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21mania#rules + yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Hard"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 2, 45).TotalMilliseconds, "Insane"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 3, 30).TotalMilliseconds, "Expert"); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs index 4adabfa4d7..17997ed463 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs @@ -13,6 +13,12 @@ namespace osu.Game.Rulesets.Mania.Edit { private readonly List checks = new List { + // Compose + new CheckManiaConcurrentObjects(), + + // Spread + new CheckManiaLowestDiffDrainTime(), + // Settings new CheckKeyCount(), new CheckManiaAbnormalDifficultySettings(), diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs index d0eb8c1e6e..4eb54e6366 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -1,17 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; namespace osu.Game.Rulesets.Mania.Edit { public partial class ManiaBlueprintContainer : ComposeBlueprintContainer { - public ManiaBlueprintContainer(HitObjectComposer composer) + public new ManiaHitObjectComposer Composer => (ManiaHitObjectComposer)base.Composer; + + public ManiaBlueprintContainer(ManiaHitObjectComposer composer) : base(composer) { } @@ -33,5 +39,22 @@ namespace osu.Game.Rulesets.Mania.Edit protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); protected sealed override DragBox CreateDragBox() => new ScrollingDragBox(Composer.Playfield); + + protected override bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints) + { + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // The final movement position, relative to movementBlueprintOriginalPosition. + Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled; + + // Retrieve a snapped position. + var result = Composer.FindSnappedPositionAndTime(movePosition); + + var referenceBlueprint = blueprints.First().blueprint; + bool moved = SelectionHandler.HandleMovement(new MoveSelectionEvent(referenceBlueprint, result.ScreenSpacePosition - referenceBlueprint.ScreenSpaceSelectionPoint)); + if (moved) + ApplySnapResultTime(result, referenceBlueprint.Item.StartTime); + return moved; + } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index 926a4b2736..bc20456722 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -19,6 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Edit { + [Cached] public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer { private DrawableManiaEditorRuleset drawableRuleset = null!; @@ -64,11 +65,11 @@ namespace osu.Game.Rulesets.Mania.Edit return; List remainingHitObjects = EditorBeatmap.HitObjects.Cast().Where(h => h.StartTime >= timestamp).ToList(); - string[] objectDescriptions = objectDescription.Split(',').ToArray(); + string[] objectDescriptions = objectDescription.Split(','); for (int i = 0; i < objectDescriptions.Length; i++) { - string[] split = objectDescriptions[i].Split('|').ToArray(); + string[] split = objectDescriptions[i].Split('|'); if (split.Length != 2) continue; diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs index 48e59877df..835a37e064 100644 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs @@ -89,6 +89,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup { Caption = EditorSetupStrings.BaseVelocity, HintText = EditorSetupStrings.BaseVelocityDescription, + KeyboardStep = 0.1f, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, @@ -103,6 +104,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup { Caption = EditorSetupStrings.TickRate, HintText = EditorSetupStrings.TickRateDescription, + KeyboardStep = 1, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, @@ -134,7 +136,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup updatingKeyCount = true; - editor.Reload().ContinueWith(t => + editor.SaveAndReload().ContinueWith(t => { if (!t.GetResultSafely()) { diff --git a/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgementResult.cs b/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgementResult.cs new file mode 100644 index 0000000000..d1453cb7ad --- /dev/null +++ b/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgementResult.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.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Objects; + +namespace osu.Game.Rulesets.Mania.Judgements +{ + public class HoldNoteJudgementResult : JudgementResult + { + private Stack<(double time, bool holding)> holdingState { get; } = new Stack<(double, bool)>(); + + public HoldNoteJudgementResult(HoldNote hitObject, Judgement judgement) + : base(hitObject, judgement) + { + holdingState.Push((double.NegativeInfinity, false)); + } + + private (double time, bool holding) getLastReport(double currentTime) + { + while (holdingState.Peek().time > currentTime) + holdingState.Pop(); + + return holdingState.Peek(); + } + + public bool IsHolding(double currentTime) => getLastReport(currentTime).holding; + + public bool DroppedHoldAfter(double time) + { + foreach (var state in holdingState) + { + if (state.time >= time && !state.holding) + return true; + } + + return false; + } + + public void ReportHoldState(double currentTime, bool holding) + { + var lastReport = getLastReport(currentTime); + if (holding != lastReport.holding) + holdingState.Push((currentTime, holding)); + } + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs index 8c6efbc72d..3f7a018dd1 100644 --- a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs +++ b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; using osu.Game.Rulesets.Filter; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Mods; @@ -17,20 +19,80 @@ namespace osu.Game.Rulesets.Mania { public class ManiaFilterCriteria : IRulesetFilterCriteria { - private FilterCriteria.OptionalRange keys; + private readonly HashSet includedKeyCounts = Enumerable.Range(1, LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT).ToHashSet(); + private FilterCriteria.OptionalRange longNotePercentage; public bool Matches(BeatmapInfo beatmapInfo, FilterCriteria criteria) { - return !keys.HasFilter || keys.IsInRange(ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), criteria.Mods)); + int keyCount = ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), criteria.Mods); + + bool keyCountMatch = includedKeyCounts.Contains(keyCount); + bool longNotePercentageMatch = !longNotePercentage.HasFilter || (!isConvertedBeatmap(beatmapInfo) && longNotePercentage.IsInRange(calculateLongNotePercentage(beatmapInfo))); + + return keyCountMatch && longNotePercentageMatch; } - public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) + public bool TryParseCustomKeywordCriteria(string key, Operator op, string strValues) { switch (key) { case "key": case "keys": - return FilterQueryParser.TryUpdateCriteriaRange(ref keys, op, value); + { + var keyCounts = new HashSet(); + + foreach (string strValue in strValues.Split(',')) + { + if (!int.TryParse(strValue, out int keyCount)) + return false; + + keyCounts.Add(keyCount); + } + + int? singleKeyCount = keyCounts.Count == 1 ? keyCounts.Single() : null; + + switch (op) + { + case Operator.Equal: + includedKeyCounts.IntersectWith(keyCounts); + return true; + + case Operator.NotEqual: + includedKeyCounts.ExceptWith(keyCounts); + return true; + + case Operator.Less: + if (singleKeyCount == null) return false; + + includedKeyCounts.RemoveWhere(k => k >= singleKeyCount.Value); + return true; + + case Operator.LessOrEqual: + if (singleKeyCount == null) return false; + + includedKeyCounts.RemoveWhere(k => k > singleKeyCount.Value); + return true; + + case Operator.Greater: + if (singleKeyCount == null) return false; + + includedKeyCounts.RemoveWhere(k => k <= singleKeyCount.Value); + return true; + + case Operator.GreaterOrEqual: + if (singleKeyCount == null) return false; + + includedKeyCounts.RemoveWhere(k => k < singleKeyCount.Value); + return true; + + default: + return false; + } + } + + case "ln": + case "lns": + return FilterQueryParser.TryUpdateCriteriaRange(ref longNotePercentage, op, strValues); } return false; @@ -38,7 +100,7 @@ namespace osu.Game.Rulesets.Mania public bool FilterMayChangeFromMods(ValueChangedEvent> mods) { - if (keys.HasFilter) + if (includedKeyCounts.Count != LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT) { // Interpreting as the Mod type is required for equality comparison. HashSet oldSet = mods.OldValue.OfType().AsEnumerable().ToHashSet(); @@ -50,5 +112,18 @@ namespace osu.Game.Rulesets.Mania return false; } + + private static bool isConvertedBeatmap(BeatmapInfo beatmapInfo) + { + return !beatmapInfo.Ruleset.Equals(new ManiaRuleset().RulesetInfo); + } + + private static float calculateLongNotePercentage(BeatmapInfo beatmapInfo) + { + int holdNotes = beatmapInfo.EndTimeObjectCount; + int totalNotes = Math.Max(1, beatmapInfo.TotalObjectCount); + + return holdNotes / (float)totalNotes * 100; + } } } diff --git a/osu.Game.Rulesets.Mania/ManiaInputManager.cs b/osu.Game.Rulesets.Mania/ManiaInputManager.cs index 36ccf68d76..e8c993a91b 100644 --- a/osu.Game.Rulesets.Mania/ManiaInputManager.cs +++ b/osu.Game.Rulesets.Mania/ManiaInputManager.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania { - [Cached] // Used for touch input, see ColumnTouchInputArea. + [Cached] // Used for touch input, see Column.OnTouchDown/OnTouchUp. public partial class ManiaInputManager : RulesetInputManager { public ManiaInputManager(RulesetInfo ruleset, int variant) diff --git a/osu.Game.Rulesets.Mania/ManiaMobileLayout.cs b/osu.Game.Rulesets.Mania/ManiaMobileLayout.cs new file mode 100644 index 0000000000..fb41a83417 --- /dev/null +++ b/osu.Game.Rulesets.Mania/ManiaMobileLayout.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Rulesets.Mania.Configuration; + +namespace osu.Game.Rulesets.Mania +{ + public enum ManiaMobileLayout + { + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.Portrait))] + Portrait, + + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.Landscape))] + Landscape, + + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.LandscapeExpandedColumns))] + LandscapeExpandedColumns, + + [Obsolete($"Use {nameof(ManiaRulesetSetting.TouchOverlay)} instead.")] // todo: can be removed 20260211 + LandscapeWithOverlay, + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index cdc7b0a951..cc64ee0d69 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; @@ -12,6 +13,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; @@ -77,6 +79,7 @@ namespace osu.Game.Rulesets.Mania return new ManiaArgonSkinTransformer(skin, beatmap); case DefaultLegacySkin: + case RetroSkin: return new ManiaClassicSkinTransformer(skin, beatmap); case LegacySkin: @@ -161,7 +164,7 @@ namespace osu.Game.Rulesets.Mania yield return new ManiaModMirror(); if (mods.HasFlag(LegacyMods.ScoreV2)) - yield return new ModScoreV2(); + yield return new ManiaModScoreV2(); } public override LegacyMods ConvertToLegacyMods(Mod[] mods) @@ -296,7 +299,7 @@ namespace osu.Game.Rulesets.Mania case ModType.System: return new Mod[] { - new ModScoreV2(), + new ManiaModScoreV2(), }; default: @@ -414,6 +417,70 @@ namespace osu.Game.Rulesets.Mania }), true) }; + /// + public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) + { + BeatmapDifficulty adjustedDifficulty = base.GetAdjustedDisplayDifficulty(beatmapInfo, mods); + + // notably, in mania, hit windows are designed to be independent of track playback rate (see `ManiaHitWindows.SpeedMultiplier`). + // *however*, to not make matters *too* simple, mania Hard Rock and Easy differ from all other rulesets + // in that they apply multipliers *to hit window durations directly* rather than to the Overall Difficulty attribute itself. + // because the duration of hit window durations as a function of OD is not a linear function, + // this means that multiplying the OD is *not* the same thing as multiplying the hit window duration. + // in fact, the second operation is *much* harsher and will produce values much farther outside of normal operating range + // (even negative in the case of Easy). + // stable handles this wrong on song select and just assumes that it can handle mania EZ / HR the same way as all other rulesets. + + double perfectHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, ManiaHitWindows.PERFECT_WINDOW_RANGE); + + if (mods.Any(m => m is ManiaModHardRock)) + perfectHitWindow /= ManiaModHardRock.HIT_WINDOW_DIFFICULTY_MULTIPLIER; + else if (mods.Any(m => m is ManiaModEasy)) + perfectHitWindow /= ManiaModEasy.HIT_WINDOW_DIFFICULTY_MULTIPLIER; + + adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(perfectHitWindow, ManiaHitWindows.PERFECT_WINDOW_RANGE); + adjustedDifficulty.CircleSize = ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), mods); + + return adjustedDifficulty; + } + + public override IEnumerable GetBeatmapAttributesForDisplay(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) + { + // a special touch-up of key count is required to the original difficulty, since key conversion mods are not `IApplicableToDifficulty` + var originalDifficulty = new BeatmapDifficulty(beatmapInfo.Difficulty) + { + CircleSize = ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), []) + }; + var adjustedDifficulty = GetAdjustedDisplayDifficulty(beatmapInfo, mods); + var colours = new OsuColour(); + + yield return new RulesetBeatmapAttribute(SongSelectStrings.KeyCount, @"KC", originalDifficulty.CircleSize, adjustedDifficulty.CircleSize, 18) + { + Description = "Affects the number of key columns on the playfield." + }; + + var hitWindows = new ManiaHitWindows(); + hitWindows.SetDifficulty(adjustedDifficulty.OverallDifficulty); + hitWindows.IsConvert = !beatmapInfo.Ruleset.Equals(RulesetInfo); + hitWindows.ClassicModActive = mods.Any(m => m is ManiaModClassic); + yield return new RulesetBeatmapAttribute(SongSelectStrings.Accuracy, @"OD", originalDifficulty.OverallDifficulty, adjustedDifficulty.OverallDifficulty, 10) + { + Description = "Affects timing requirements for notes.", + AdditionalMetrics = hitWindows.GetAllAvailableWindows() + .Reverse() + .Select(window => new RulesetBeatmapAttribute.AdditionalMetric( + $"{window.result.GetDescription().ToUpperInvariant()} hit window", + LocalisableString.Interpolate($@"±{hitWindows.WindowFor(window.result):0.##} ms"), + colours.ForHitResult(window.result) + )).ToArray() + }; + + yield return new RulesetBeatmapAttribute(SongSelectStrings.HPDrain, @"HP", originalDifficulty.DrainRate, adjustedDifficulty.DrainRate, 10) + { + Description = "Affects the harshness of health drain and the health penalties for missing." + }; + } + public override IRulesetFilterCriteria CreateRulesetFilterCriteria() { return new ManiaFilterCriteria(); diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 17add32513..791f46d407 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.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 System; +using System.Linq; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -44,8 +47,26 @@ namespace osu.Game.Rulesets.Mania Keywords = new[] { "color" }, LabelText = RulesetSettingsStrings.TimingBasedColouring, Current = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring), - } + }, }; + + Add(new SettingsCheckbox + { + LabelText = RulesetSettingsStrings.TouchOverlay, + Current = config.GetBindable(ManiaRulesetSetting.TouchOverlay) + }); + + if (RuntimeInfo.IsMobile) + { + Add(new SettingsEnumDropdown + { + LabelText = RulesetSettingsStrings.MobileLayout, + Current = config.GetBindable(ManiaRulesetSetting.MobileLayout), +#pragma warning disable CS0618 // Type or member is obsolete + Items = Enum.GetValues().Where(l => l != ManiaMobileLayout.LandscapeWithOverlay), +#pragma warning restore CS0618 // Type or member is obsolete + }); + } } private partial class ManiaScrollSlider : RoundedSliderBar diff --git a/osu.Game.Rulesets.Mania/Mods/IManiaRateAdjustmentMod.cs b/osu.Game.Rulesets.Mania/Mods/IManiaRateAdjustmentMod.cs index ea01bd4436..ca364a1ec8 100644 --- a/osu.Game.Rulesets.Mania/Mods/IManiaRateAdjustmentMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/IManiaRateAdjustmentMod.cs @@ -2,12 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Mods { @@ -17,29 +15,21 @@ namespace osu.Game.Rulesets.Mania.Mods /// /// Historically, in osu!mania, hit windows are expected to adjust relative to the gameplay rate such that the real-world hit window remains the same. /// - public interface IManiaRateAdjustmentMod : IApplicableToDifficulty, IApplicableToHitObject + public interface IManiaRateAdjustmentMod : IApplicableToHitObject { BindableNumber SpeedChange { get; } - HitWindows HitWindows { get; set; } - - void IApplicableToDifficulty.ApplyToDifficulty(BeatmapDifficulty difficulty) - { - HitWindows = new ManiaHitWindows(SpeedChange.Value); - HitWindows.SetDifficulty(difficulty.OverallDifficulty); - } - void IApplicableToHitObject.ApplyToHitObject(HitObject hitObject) { switch (hitObject) { case Note: - hitObject.HitWindows = HitWindows; + ((ManiaHitWindows)hitObject.HitWindows).SpeedMultiplier = SpeedChange.Value; break; case HoldNote hold: - hold.Head.HitWindows = HitWindows; - hold.Tail.HitWindows = HitWindows; + ((ManiaHitWindows)hold.Head.HitWindows).SpeedMultiplier = SpeedChange.Value; + ((ManiaHitWindows)hold.Tail.HitWindows).SpeedMultiplier = SpeedChange.Value; break; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs index 88d6a19822..8ff131d3c8 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Acronym => Name; public abstract int KeyCount { get; } public override ModType Type => ModType.Conversion; - public override double ScoreMultiplier => 1; // TODO: Implement the mania key mod score multiplier + public override double ScoreMultiplier => 0.9; public override bool Ranked => UsesDefaultConfiguration; public void ApplyToBeatmapConverter(IBeatmapConverter beatmapConverter) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs index 073dda9de8..5e46250dd2 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs @@ -1,11 +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.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModClassic : ModClassic + public class ManiaModClassic : ModClassic, IApplicableToBeatmap { + public void ApplyToBeatmap(IBeatmap beatmap) + { + bool isConvert = !beatmap.BeatmapInfo.Ruleset.Equals(new ManiaRuleset().RulesetInfo); + + foreach (var ho in beatmap.HitObjects) + { + switch (ho) + { + case Note note: + { + var hitWindows = (ManiaHitWindows)note.HitWindows; + hitWindows.IsConvert = isConvert; + hitWindows.ClassicModActive = true; + break; + } + + case HoldNote hold: + { + var headWindows = (ManiaHitWindows)hold.Head.HitWindows; + var tailWindows = (ManiaHitWindows)hold.Tail.HitWindows; + headWindows.IsConvert = tailWindows.IsConvert = isConvert; + headWindows.ClassicModActive = tailWindows.ClassicModActive = true; + break; + } + } + } + } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs index d8e6bcd424..ab493410a5 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override LocalisableString Description => "No more tricky speed changes!"; - public override IconUsage? Icon => FontAwesome.Solid.Equals; + public override IconUsage? Icon => OsuIcon.ModConstantSpeed; public override ModType Type => ModType.Conversion; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs index eb243bfab7..3ebfcedfd1 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs @@ -4,8 +4,10 @@ using System; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods @@ -14,6 +16,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public override string Name => "Cover"; public override string Acronym => "CO"; + public override IconUsage? Icon => OsuIcon.ModCover; public override LocalisableString Description => @"Decrease the playfield's viewing area."; @@ -29,6 +32,8 @@ namespace osu.Game.Rulesets.Mania.Mods public override bool Ranked => false; + public override bool ValidForFreestyleAsRequiredMod => false; + [SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")] public override BindableNumber Coverage { get; } = new BindableFloat(0.5f) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs index dbe2a9a9fc..9e9d671006 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs @@ -1,14 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModDaycore : ModDaycore, IManiaRateAdjustmentMod { - public HitWindows HitWindows { get; set; } = new ManiaHitWindows(); } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs index 0817f8f9fc..ce70fdf73a 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs @@ -7,5 +7,15 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModDifficultyAdjust : ModDifficultyAdjust { + public override DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 10, + // Use larger extended limits for mania to include OD values that occur with EZ or HR enabled + ExtendedMaxValue = 15, + ExtendedMinValue = -15, + ReadCurrentFromDifficulty = diff => diff.OverallDifficulty, + }; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs index bea1a14110..043fa1c40c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs @@ -1,16 +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.Scoring; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModDoubleTime : ModDoubleTime, IManiaRateAdjustmentMod { - public HitWindows HitWindows { get; set; } = new ManiaHitWindows(); - // For now, all rate-increasing mods should be given a 1x multiplier in mania because it doesn't always // make the map harder and is more of a personal preference. // In the future, we can consider adjusting this by experimenting with not applying the hitwindow leniency. diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs index 2457aa75d7..e6b3541154 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.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.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mods; @@ -13,6 +15,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Name => "Dual Stages"; public override string Acronym => "DS"; public override LocalisableString Description => @"Double the stages, double the fun!"; + public override IconUsage? Icon => OsuIcon.ModDualStages; public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs index 5c8cd6a5ae..16872c45c4 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs @@ -2,12 +2,32 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Localisation; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModEasy : ModEasyWithExtraLives + public class ManiaModEasy : ModEasyWithExtraLives, IApplicableToHitObject { - public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and extra lives!"; + + public const double HIT_WINDOW_DIFFICULTY_MULTIPLIER = 1 / 1.4; + + void IApplicableToHitObject.ApplyToHitObject(HitObject hitObject) + { + switch (hitObject) + { + case Note: + ((ManiaHitWindows)hitObject.HitWindows).DifficultyMultiplier = HIT_WINDOW_DIFFICULTY_MULTIPLIER; + break; + + case HoldNote hold: + ((ManiaHitWindows)hold.Head.HitWindows).DifficultyMultiplier = HIT_WINDOW_DIFFICULTY_MULTIPLIER; + ((ManiaHitWindows)hold.Tail.HitWindows).DifficultyMultiplier = HIT_WINDOW_DIFFICULTY_MULTIPLIER; + break; + } + } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index 54a0b8f36d..337fd61b91 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -3,7 +3,9 @@ using System; using System.Linq; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods @@ -12,8 +14,10 @@ namespace osu.Game.Rulesets.Mania.Mods { public override string Name => "Fade In"; public override string Acronym => "FI"; + public override IconUsage? Icon => OsuIcon.ModFadeIn; public override LocalisableString Description => @"Keys appear out of nowhere!"; public override double ScoreMultiplier => 1; + public override bool ValidForFreestyleAsRequiredMod => false; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs index b0fbb11396..f8d2758914 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs @@ -1,14 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModHalfTime : ModHalfTime, IManiaRateAdjustmentMod { - public HitWindows HitWindows { get; set; } = new ManiaHitWindows(); } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.cs index 189c4b3a5f..13f86bd641 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHardRock.cs @@ -1,13 +1,33 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModHardRock : ModHardRock + public class ManiaModHardRock : ModHardRock, IApplicableToHitObject { public override double ScoreMultiplier => 1; public override bool Ranked => false; + + public const double HIT_WINDOW_DIFFICULTY_MULTIPLIER = 1.4; + + void IApplicableToHitObject.ApplyToHitObject(HitObject hitObject) + { + switch (hitObject) + { + case Note: + ((ManiaHitWindows)hitObject.HitWindows).DifficultyMultiplier = HIT_WINDOW_DIFFICULTY_MULTIPLIER; + break; + + case HoldNote hold: + ((ManiaHitWindows)hold.Head.HitWindows).DifficultyMultiplier = HIT_WINDOW_DIFFICULTY_MULTIPLIER; + ((ManiaHitWindows)hold.Tail.HitWindows).DifficultyMultiplier = HIT_WINDOW_DIFFICULTY_MULTIPLIER; + break; + } + } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs index eba0b2effe..6332e2a928 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs @@ -9,6 +9,8 @@ using osu.Game.Rulesets.Mods; using osu.Framework.Graphics.Sprites; using System.Collections.Generic; using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.Mods @@ -23,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override LocalisableString Description => @"Replaces all hold notes with normal notes."; - public override IconUsage? Icon => FontAwesome.Solid.DotCircle; + public override IconUsage? Icon => OsuIcon.ModHoldOff; public override ModType Type => ModType.Conversion; @@ -33,6 +35,8 @@ namespace osu.Game.Rulesets.Mania.Mods { var maniaBeatmap = (ManiaBeatmap)beatmap; + double mostCommonBeatLengthBefore = beatmap.GetMostCommonBeatLength(); + var newObjects = new List(); foreach (var h in beatmap.HitObjects.OfType()) @@ -47,6 +51,17 @@ namespace osu.Game.Rulesets.Mania.Mods } maniaBeatmap.HitObjects = maniaBeatmap.HitObjects.OfType().Concat(newObjects).OrderBy(h => h.StartTime).ToList(); + + double mostCommonBeatLengthAfter = beatmap.GetMostCommonBeatLength(); + + // the process of removing hold notes can result in shortening the beatmap's play time, + // and therefore, as a side effect, changing the most common BPM, which will change scroll speed. + // to compensate for this, apply a multiplier to effect points in order to maintain the beatmap's original intended scroll speed. + if (!Precision.AlmostEquals(mostCommonBeatLengthBefore, mostCommonBeatLengthAfter)) + { + foreach (var effectPoint in beatmap.ControlPointInfo.EffectPoints) + effectPoint.ScrollSpeed *= mostCommonBeatLengthBefore / mostCommonBeatLengthAfter; + } } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs index ef9154d180..f0fc9c0685 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; @@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override LocalisableString Description => "Hold the keys. To the beat."; - public override IconUsage? Icon => FontAwesome.Solid.YinYang; + public override IconUsage? Icon => OsuIcon.ModInvert; public override ModType Type => ModType.Conversion; @@ -42,8 +43,7 @@ namespace osu.Game.Rulesets.Mania.Mods var locations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples)) .Concat(column.OfType().SelectMany(h => new[] { - (startTime: h.StartTime, samples: h.GetNodeSamples(0)), - (startTime: h.EndTime, samples: h.GetNodeSamples(1)) + (startTime: h.StartTime, samples: h.GetNodeSamples(0)) })) .OrderBy(h => h.startTime).ToList(); @@ -64,6 +64,7 @@ namespace osu.Game.Rulesets.Mania.Mods StartTime = locations[i].startTime, Duration = duration, NodeSamples = new List> { locations[i].samples, Array.Empty() } + // intentionally don't play sliding samples here, it doesn't work in this mod. }); } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs index 7dd0c499da..290e40fbf3 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 1; public override string Name => "One Key"; public override string Acronym => "1K"; + public override IconUsage? Icon => OsuIcon.ModOneKey; public override LocalisableString Description => @"Play with one key."; public override bool Ranked => false; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs index a6c57d4597..18687148df 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 10; public override string Name => "Ten Keys"; public override string Acronym => "10K"; + public override IconUsage? Icon => OsuIcon.ModTenKeys; public override LocalisableString Description => @"Play with ten keys."; public override bool Ranked => false; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs index 0d04395a52..041d38b98a 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 2; public override string Name => "Two Keys"; public override string Acronym => "2K"; + public override IconUsage? Icon => OsuIcon.ModTwoKeys; public override LocalisableString Description => @"Play with two keys."; public override bool Ranked => false; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs index c83b0979ee..fea5366811 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 3; public override string Name => "Three Keys"; public override string Acronym => "3K"; + public override IconUsage? Icon => OsuIcon.ModThreeKeys; public override LocalisableString Description => @"Play with three keys."; public override bool Ranked => false; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs index d3a4546dce..4a9fe7e3df 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 4; public override string Name => "Four Keys"; public override string Acronym => "4K"; + public override IconUsage? Icon => OsuIcon.ModFourKeys; public override LocalisableString Description => @"Play with four keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs index 693182a952..aea2fe9bbe 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 5; public override string Name => "Five Keys"; public override string Acronym => "5K"; + public override IconUsage? Icon => OsuIcon.ModFiveKeys; public override LocalisableString Description => @"Play with five keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs index ab911292f7..e66ea32585 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 6; public override string Name => "Six Keys"; public override string Acronym => "6K"; + public override IconUsage? Icon => OsuIcon.ModSixKeys; public override LocalisableString Description => @"Play with six keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs index ab401ef1d0..07aa60a0a8 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 7; public override string Name => "Seven Keys"; public override string Acronym => "7K"; + public override IconUsage? Icon => OsuIcon.ModSevenKeys; public override LocalisableString Description => @"Play with seven keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs index b3e8a45dda..b6b2016790 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 8; public override string Name => "Eight Keys"; public override string Acronym => "8K"; + public override IconUsage? Icon => OsuIcon.ModEightKeys; public override LocalisableString Description => @"Play with eight keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs index 5972cbf0fe..089bb0402b 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mania.Mods { @@ -10,6 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 9; public override string Name => "Nine Keys"; public override string Acronym => "9K"; + public override IconUsage? Icon => OsuIcon.ModNineKeys; public override LocalisableString Description => @"Play with nine keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs index 7e5e80db6c..0eb4ddc7d0 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs @@ -2,16 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModNightcore : ModNightcore, IManiaRateAdjustmentMod { - public HitWindows HitWindows { get; set; } = new ManiaHitWindows(); - // For now, all rate-increasing mods should be given a 1x multiplier in mania because it doesn't always // make the map any harder and is more of a personal preference. // In the future, we can consider adjusting this by experimenting with not applying the hitwindow leniency. diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs index b5490aa950..d72e2ce70c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs @@ -4,8 +4,10 @@ using System; using System.Linq; using System.Threading; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; @@ -26,6 +28,8 @@ namespace osu.Game.Rulesets.Mania.Mods public override double ScoreMultiplier => 0.9; + public override IconUsage? Icon => OsuIcon.ModNoRelease; + public override ModType Type => ModType.DifficultyReduction; public override Type[] IncompatibleMods => new[] { typeof(ManiaModHoldOff) }; @@ -62,7 +66,7 @@ namespace osu.Game.Rulesets.Mania.Mods protected override void CheckForResult(bool userTriggered, double timeOffset) { // apply perfect once the tail is reached - if (HoldNote.HoldStartTime != null && timeOffset >= 0) + if (HoldNote.IsHolding.Value && timeOffset >= 0) ApplyResult(GetCappedResult(HitResult.Perfect)); else base.CheckForResult(userTriggered, timeOffset); @@ -80,7 +84,9 @@ namespace osu.Game.Rulesets.Mania.Mods StartTime = hold.StartTime; Duration = hold.Duration; Column = hold.Column; + Samples = hold.Samples; NodeSamples = hold.NodeSamples; + PlaySlidingSamples = hold.PlaySlidingSamples; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.cs index b02a18c9f4..7ce750f4f8 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModPerfect.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.Bindables; +using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -9,13 +11,16 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModPerfect : ModPerfect { + [SettingSource("Require perfect hits")] + public BindableBool RequirePerfectHits { get; } = new BindableBool(); + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) { if (!isRelevantResult(result.Judgement.MinResult) && !isRelevantResult(result.Judgement.MaxResult) && !isRelevantResult(result.Type)) return false; // Mania allows imperfect "Great" hits without failing. - if (result.Judgement.MaxResult == HitResult.Perfect) + if (result.Judgement.MaxResult == HitResult.Perfect && !RequirePerfectHits.Value) return result.Type < HitResult.Great; return result.Type != result.Judgement.MaxResult; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModScoreV2.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModScoreV2.cs new file mode 100644 index 0000000000..46bb75a480 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModScoreV2.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModScoreV2 : ModScoreV2, IApplicableToBeatmap + { + public void ApplyToBeatmap(IBeatmap beatmap) + { + foreach (var ho in beatmap.HitObjects) + { + switch (ho) + { + case Note note: + { + var hitWindows = (ManiaHitWindows)note.HitWindows; + hitWindows.ScoreV2Active = true; + break; + } + + case HoldNote hold: + { + var headWindows = (ManiaHitWindows)hold.Head.HitWindows; + var tailWindows = (ManiaHitWindows)hold.Tail.HitWindows; + headWindows.ScoreV2Active = tailWindows.ScoreV2Active = true; + break; + } + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModWithPlayfieldCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModWithPlayfieldCover.cs index 864ef6c3d6..1bc16112c5 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModWithPlayfieldCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModWithPlayfieldCover.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mania.Mods foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) { - HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; + HitObjectContainer hoc = column.HitObjectContainer; Container hocParent = (Container)hoc.Parent!; hocParent.Remove(hoc, false); diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs index 25fed1a84c..c9fc0763a8 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs @@ -26,9 +26,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables : base(barLine) { RelativeSizeAxes = Axes.X; + Height = 1; } - [BackgroundDependencyLoader] + [BackgroundDependencyLoader(true)] private void load() { AddInternal(new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.BarLine), _ => new DefaultBarLine()) @@ -36,8 +37,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Anchor = Anchor.Centre, Origin = Anchor.Centre, }); - - Major.BindValueChanged(major => Height = major.NewValue ? 1.7f : 1.2f, true); } protected override void OnApply() diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 9c56f0473c..210cd2a103 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -11,6 +11,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Audio; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -29,9 +31,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { public override bool DisplayResult => false; - public IBindable IsHitting => isHitting; + public IBindable IsHolding => isHolding; - private readonly Bindable isHitting = new Bindable(); + private readonly Bindable isHolding = new Bindable(); public DrawableHoldNoteHead Head => headContainer.Child; public DrawableHoldNoteTail Tail => tailContainer.Child; @@ -55,16 +57,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables private SkinnableDrawable bodyPiece; - /// - /// Time at which the user started holding this hold note. Null if the user is not holding this hold note. - /// - public double? HoldStartTime { get; private set; } - - /// - /// Used to decide whether to visually clamp the hold note to the judgement line. - /// - private double? releaseTime; - public DrawableHoldNote() : this(null) { @@ -126,7 +118,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { base.LoadComplete(); - isHitting.BindValueChanged(updateSlidingSample, true); + isHolding.BindValueChanged(updateSlidingSample, true); } protected override void OnApply() @@ -134,8 +126,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables base.OnApply(); sizingContainer.Size = Vector2.One; - HoldStartTime = null; - releaseTime = null; } protected override void AddNestedHitObject(DrawableHitObject hitObject) @@ -207,6 +197,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public override void OnKilled() { base.OnKilled(); + // flush the final state of holding on kill. + // this matters because some skin implementations like legacy skin + // insert drawables in the hierarchy that are not a child of this DHO + // (see `LegacyBodyPiece` and related machinations with `lightContainer` being added at column level) + isHolding.Value = Result.IsHolding(Time.Current); (bodyPiece.Drawable as IHoldNoteBody)?.Recycle(); } @@ -214,11 +209,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { base.Update(); - if (Time.Current < releaseTime) - releaseTime = null; - - if (Time.Current < HoldStartTime) - endHold(); + isHolding.Value = Result.IsHolding(Time.Current); // Pad the full size container so its contents (i.e. the masking container) reach under the tail. // This is required for the tail to not be masked away, since it lies outside the bounds of the hold note. @@ -249,7 +240,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables // // As per stable, this should not apply for early hits, waiting until the object starts to touch the // judgement area first. - if (Head.IsHit && releaseTime == null && DrawHeight > 0) + if (Head.IsHit && !Result.DroppedHoldAfter(HitObject.StartTime) && DrawHeight > 0) { // How far past the hit target this hold note is. float yOffset = Direction.Value == ScrollingDirection.Up ? -Y : Y; @@ -260,6 +251,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables sizingContainer.Height = 1; } + protected override JudgementResult CreateResult(Judgement judgement) => new HoldNoteJudgementResult(HitObject, judgement); + + public new HoldNoteJudgementResult Result => (HoldNoteJudgementResult)base.Result; + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (Tail.AllJudged) @@ -274,7 +269,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Body.TriggerResult(Tail.IsHit); // Important that this is always called when a result is applied. - endHold(); + Result.ReportHoldState(Time.Current, false); } } @@ -283,7 +278,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables base.MissForcefully(); // Important that this is always called when a result is applied. - endHold(); + Result.ReportHoldState(Time.Current, false); } public bool OnPressed(KeyBindingPressEvent e) @@ -317,8 +312,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables if (timeOffset < -Head.HitObject.HitWindows.WindowFor(HitResult.Miss)) return; - HoldStartTime = Time.Current; - isHitting.Value = true; + Result.ReportHoldState(Time.Current, true); } public void OnReleased(KeyBindingReleaseEvent e) @@ -337,22 +331,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables // the user has released too early (before the tail). // // In such a case, we want to record this against the DrawableHoldNoteBody. - if (HoldStartTime != null) + if (isHolding.Value) { Tail.UpdateResult(); Body.TriggerResult(Tail.IsHit); - endHold(); - releaseTime = Time.Current; + Result.ReportHoldState(Time.Current, false); } } - private void endHold() - { - HoldStartTime = null; - isHitting.Value = false; - } - protected override void LoadSamples() { // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. @@ -368,7 +355,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables private void updateSlidingSample(ValueChangedEvent tracking) { - if (tracking.NewValue) + if (tracking.NewValue && HitObject.PlaySlidingSamples) slidingSample?.Play(); else slidingSample?.Stop(); diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index 98060dd226..15c570e34a 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -86,6 +86,11 @@ namespace osu.Game.Rulesets.Mania.Objects /// public HoldNoteBody Body { get; protected set; } + /// + /// Whether sliding samples should be played when held. + /// + public bool PlaySlidingSamples { get; init; } + public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset; protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs index 25ad6b997d..c8c8867bc6 100644 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs @@ -25,7 +25,11 @@ namespace osu.Game.Rulesets.Mania.Objects #region LegacyBeatmapEncoder - float IHasXPosition.X => Column; + float IHasXPosition.X + { + get => Column; + set => Column = (int)value; + } #endregion } diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs index f80c442025..abbaa374f0 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; @@ -47,5 +48,8 @@ namespace osu.Game.Rulesets.Mania.Replays return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is ManiaReplayFrame maniaFrame && Time == maniaFrame.Time && Actions.SequenceEqual(maniaFrame.Actions); } } diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs index 627f48f391..abff91926a 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs @@ -1,25 +1,107 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; +using System; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { public class ManiaHitWindows : HitWindows { - private readonly double multiplier; + public static readonly DifficultyRange PERFECT_WINDOW_RANGE = new DifficultyRange(22.4D, 19.4D, 13.9D); + private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34); + private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67); + private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97); + private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121); + private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158); - public ManiaHitWindows() - : this(1) + private double speedMultiplier = 1; + + /// + /// Multiplier used to compensate for the playback speed of the track speeding up or slowing down. + /// The goal of this multiplier is to keep hit windows independent of track speed. + /// + /// When the track speed is above 1, the hit window ranges are multiplied by , because the time elapses faster. + /// When the track speed is below 1, the hit window ranges are also multiplied by , because the time elapses slower. + /// + /// + public double SpeedMultiplier { + get => speedMultiplier; + set + { + speedMultiplier = value; + updateWindows(); + } } - public ManiaHitWindows(double multiplier) + private double difficultyMultiplier = 1; + + /// + /// Multiplier used to make the gameplay more or less difficult. + /// + /// When the is above 1, the hit windows decrease to make the gameplay harder. + /// When the is below 1, the hit windows increase to make the gameplay easier. + /// + /// + public double DifficultyMultiplier { - this.multiplier = multiplier; + get => difficultyMultiplier; + set + { + difficultyMultiplier = value; + updateWindows(); + } } + private double totalMultiplier => speedMultiplier / difficultyMultiplier; + + private double overallDifficulty; + + private bool classicModActive; + + public bool ClassicModActive + { + get => classicModActive; + set + { + classicModActive = value; + updateWindows(); + } + } + + private bool scoreV2Active; + + public bool ScoreV2Active + { + get => scoreV2Active; + set + { + scoreV2Active = value; + updateWindows(); + } + } + + private bool isConvert; + + public bool IsConvert + { + get => isConvert; + set + { + isConvert = value; + updateWindows(); + } + } + + private double perfect; + private double great; + private double good; + private double ok; + private double meh; + private double miss; + public override bool IsHitResultAllowed(HitResult result) { switch (result) @@ -36,11 +118,73 @@ namespace osu.Game.Rulesets.Mania.Scoring return false; } - protected override DifficultyRange[] GetRanges() => base.GetRanges().Select(r => - new DifficultyRange( - r.Result, - r.Min * multiplier, - r.Average * multiplier, - r.Max * multiplier)).ToArray(); + public override void SetDifficulty(double difficulty) + { + overallDifficulty = difficulty; + updateWindows(); + } + + private void updateWindows() + { + if (ClassicModActive && !ScoreV2Active) + { + if (IsConvert) + { + perfect = Math.Floor(16 * totalMultiplier) + 0.5; + great = Math.Floor((Math.Round(overallDifficulty) > 4 ? 34 : 47) * totalMultiplier) + 0.5; + good = Math.Floor((Math.Round(overallDifficulty) > 4 ? 67 : 77) * totalMultiplier) + 0.5; + ok = Math.Floor(97 * totalMultiplier) + 0.5; + meh = Math.Floor(121 * totalMultiplier) + 0.5; + miss = Math.Floor(158 * totalMultiplier) + 0.5; + } + else + { + double invertedOd = Math.Clamp(10 - overallDifficulty, 0, 10); + + perfect = Math.Floor(16 * totalMultiplier) + 0.5; + great = Math.Floor((34 + 3 * invertedOd) * totalMultiplier) + 0.5; + good = Math.Floor((67 + 3 * invertedOd) * totalMultiplier) + 0.5; + ok = Math.Floor((97 + 3 * invertedOd) * totalMultiplier) + 0.5; + meh = Math.Floor((121 + 3 * invertedOd) * totalMultiplier) + 0.5; + miss = Math.Floor((158 + 3 * invertedOd) * totalMultiplier) + 0.5; + } + } + else + { + perfect = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, PERFECT_WINDOW_RANGE) * totalMultiplier) + 0.5; + great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, great_window_range) * totalMultiplier) + 0.5; + good = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, good_window_range) * totalMultiplier) + 0.5; + ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, ok_window_range) * totalMultiplier) + 0.5; + meh = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, meh_window_range) * totalMultiplier) + 0.5; + miss = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, miss_window_range) * totalMultiplier) + 0.5; + } + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + return perfect; + + case HitResult.Great: + return great; + + case HitResult.Good: + return good; + + case HitResult.Ok: + return ok; + + case HitResult.Meh: + return meh; + + case HitResult.Miss: + return miss; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs index 57fa1c10ae..b0a14d27ab 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs @@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon AccentColour.BindTo(holdNote.AccentColour); hittingLayer.AccentColour.BindTo(holdNote.AccentColour); - ((IBindable)hittingLayer.IsHitting).BindTo(holdNote.IsHitting); + ((IBindable)hittingLayer.IsHitting).BindTo(holdNote.IsHolding); } AccentColour.BindValueChanged(colour => diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs index efd7f4f280..3c69a05003 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs @@ -103,7 +103,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon hittingLayer.AccentColour.BindTo(holdNoteTail.HoldNote.AccentColour); hittingLayer.IsHitting.UnbindBindings(); - ((IBindable)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHitting); + ((IBindable)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHolding); } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs index 0052fd8b78..bef8625ea2 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -12,6 +13,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; using osuTK; using osuTK.Graphics; @@ -19,25 +21,29 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon { public partial class ArgonJudgementPiece : TextJudgementPiece, IAnimatableJudgement { - private const float judgement_y_position = 160; + private const float judgement_y_position = -180f; private RingExplosion? ringExplosion; [Resolved] private OsuColour colours { get; set; } = null!; + private IBindable direction = null!; + public ArgonJudgementPiece(HitResult result) : base(result) { AutoSizeAxes = Axes.Both; Origin = Anchor.Centre; - Y = judgement_y_position; } [BackgroundDependencyLoader] - private void load() + private void load(IScrollingInfo scrollingInfo) { + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); + if (Result.IsHit()) { AddInternal(ringExplosion = new RingExplosion(Result) @@ -47,6 +53,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon } } + private void onDirectionChanged() + { + Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position; + } + protected override SpriteText CreateJudgementText() => new OsuSpriteText { @@ -78,7 +90,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon this.ScaleTo(1.6f); this.ScaleTo(1, 100, Easing.In); - this.MoveToY(judgement_y_position); + this.MoveToY(direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position); this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); this.RotateTo(0); diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index c37c18081a..a71b8aa982 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -9,7 +9,9 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Skinning.Argon @@ -38,7 +40,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case GlobalSkinnableContainers.MainHUDComponents: return new DefaultSkinComponentsContainer(container => { + var leaderboard = container.OfType().FirstOrDefault(); var combo = container.ChildrenOfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + if (leaderboard != null) + leaderboard.Position = new Vector2(36, 115); if (combo != null) { @@ -47,9 +54,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon combo.Origin = Anchor.Centre; combo.Y = 200; } + + if (spectatorList != null) + spectatorList.Position = new Vector2(36, -66); }) { + new DrawableGameplayLeaderboard(), new ArgonManiaComboCounter(), + new SpectatorList + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } }; } @@ -120,8 +136,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon switch (maniaLookup.Lookup) { - case LegacyManiaSkinConfigurationLookups.ColumnSpacing: - return SkinUtils.As(new Bindable(2)); + case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing: + case LegacyManiaSkinConfigurationLookups.RightColumnSpacing: + return SkinUtils.As(new Bindable(1)); case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: case LegacyManiaSkinConfigurationLookups.StagePaddingTop: @@ -135,7 +152,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon return SkinUtils.As(new Bindable(width)); case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: - var colour = getColourForLayout(columnIndex, stage); return SkinUtils.As(new Bindable(colour)); diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs index ef75e9df11..05fba1241f 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBarLine.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default [BackgroundDependencyLoader] private void load(DrawableHitObject drawableHitObject) { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; // Avoid flickering due to no anti-aliasing of boxes by default. var edgeSmoothness = new Vector2(0.3f); @@ -75,6 +75,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default private void updateMajor(ValueChangedEvent major) { + Height = major.NewValue ? 1.7f : 1.2f; + mainLine.Alpha = major.NewValue ? 0.5f : 0.2f; leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0; } diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBodyPiece.cs index 9f5ee0846f..67792e9027 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBodyPiece.cs @@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default var holdNote = (DrawableHoldNote)drawableObject; AccentColour.BindTo(drawableObject.AccentColour); - IsHitting.BindTo(holdNote.IsHitting); + IsHitting.BindTo(holdNote.IsHolding); } AccentColour.BindValueChanged(onAccentChanged, true); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBarLine.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBarLine.cs new file mode 100644 index 0000000000..ce48c49b2e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBarLine.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Legacy +{ + public partial class LegacyBarLine : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + float skinHeight = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.BarLineHeight)?.Value ?? 1; + + RelativeSizeAxes = Axes.X; + Height = 1.2f * skinHeight; + Colour = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.BarLineColour)?.Value ?? Color4.White; + + // Avoid flickering due to no anti-aliasing of boxes by default. + var edgeSmoothness = new Vector2(0.3f); + + AddInternal(new Box + { + Name = "Bar line", + EdgeSmoothness = edgeSmoothness, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs index 6de0752671..606f83201b 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs @@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy var wrapMode = bodyStyle == LegacyNoteBodyStyle.Stretch ? WrapMode.ClampToEdge : WrapMode.Repeat; direction.BindTo(scrollingInfo.Direction); - isHitting.BindTo(holdNote.IsHitting); + isHitting.BindTo(holdNote.IsHolding); bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30)?.With(d => { diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs index d21a8cd140..5ece5df66f 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs @@ -2,13 +2,14 @@ // 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.Animations; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Skinning.Legacy @@ -23,21 +24,21 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy this.result = result; this.animation = animation; - Anchor = Anchor.Centre; Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; } + private IBindable direction = null!; + + [Resolved] + private ISkinSource skin { get; set; } = null!; + [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load(IScrollingInfo scrollingInfo) { - float? scorePosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ScorePosition)?.Value; - - if (scorePosition != null) - scorePosition -= Stage.HIT_TARGET_POSITION + 150; - - Y = scorePosition ?? 0; + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); InternalChild = animation.With(d => { @@ -46,6 +47,25 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }); } + private void onDirectionChanged() + { + float hitPosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? 0; + float scorePosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ScorePosition)?.Value ?? 0; + + float hitPositionFromTop = 480f * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR - hitPosition; + + if (scorePosition > hitPositionFromTop / 2f) + { + Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + Y = direction.Value == ScrollingDirection.Up ? hitPositionFromTop - scorePosition : scorePosition - hitPositionFromTop; + } + else + { + Anchor = direction.Value == ScrollingDirection.Up ? Anchor.BottomCentre : Anchor.TopCentre; + Y = direction.Value == ScrollingDirection.Up ? -scorePosition : scorePosition; + } + } + public void PlayAnimation() { (animation as IFramedAnimation)?.GotoFrame(0); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs index 4291ec3c13..c9c655ef7d 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy private Drawable noteAnimation = null!; - private float? minimumColumnWidth; + private float? widthForNoteHeightScale; public LegacyNotePiece() { @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy [BackgroundDependencyLoader] private void load(ISkinSource skin, IScrollingInfo scrollingInfo) { - minimumColumnWidth = skin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.MinimumColumnWidth))?.Value; + widthForNoteHeightScale = skin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.WidthForNoteHeightScale))?.Value; InternalChild = directionContainer = new Container { @@ -60,9 +60,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy if (texture != null) { - // The height is scaled to the minimum column width, if provided. - float minimumWidth = minimumColumnWidth ?? DrawWidth; - noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), texture.DisplayWidth); + float noteHeight = widthForNoteHeightScale ?? DrawWidth; + noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, noteHeight), texture.DisplayWidth); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 8f425edc44..addb96d2c3 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -15,7 +15,9 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { @@ -62,11 +64,13 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy private readonly Lazy hasKeyTexture; private readonly ManiaBeatmap beatmap; + private readonly bool isBeatmapConverted; public ManiaLegacySkinTransformer(ISkin skin, IBeatmap beatmap) : base(skin) { this.beatmap = (ManiaBeatmap)beatmap; + isBeatmapConverted = !beatmap.BeatmapInfo.Ruleset.Equals(new ManiaRuleset().RulesetInfo); isLegacySkin = new Lazy(() => GetConfig(SkinConfiguration.LegacySetting.Version) != null); hasKeyTexture = new Lazy(() => @@ -95,6 +99,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return new DefaultSkinComponentsContainer(container => { var combo = container.ChildrenOfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + var leaderboard = container.OfType().FirstOrDefault(); if (combo != null) { @@ -102,9 +108,25 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy combo.Origin = Anchor.Centre; combo.Y = this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ComboPosition)?.Value ?? 0; } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = new Vector2(10, -10); + } + + if (leaderboard != null) + { + leaderboard.Anchor = Anchor.CentreLeft; + leaderboard.Origin = Anchor.CentreLeft; + leaderboard.X = 10; + } }) { new LegacyManiaComboCounter(), + new SpectatorList(), + new DrawableGameplayLeaderboard(), }; } @@ -152,7 +174,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return new LegacyStageForeground(); case ManiaSkinComponents.BarLine: - return null; // Not yet implemented. + return new LegacyBarLine(); default: throw new UnsupportedSkinComponentException(lookup); @@ -176,8 +198,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy public override ISample GetSample(ISampleInfo sampleInfo) { - // layered hit sounds never play in mania - if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) + // layered hit sounds never play in mania-native beatmaps (but do play on converts) + if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered && !isBeatmapConverted) return new SampleVirtual(); return base.GetSample(sampleInfo); diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index c05a8f2a29..dec30043f5 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -13,6 +12,7 @@ using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Game.Extensions; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Skinning; @@ -45,11 +45,11 @@ namespace osu.Game.Rulesets.Mania.UI internal readonly Container TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }; - private DrawablePool hitExplosionPool; + private DrawablePool hitExplosionPool = null!; private readonly OrderedHitPolicy hitPolicy; public Container UnderlayElements => HitObjectArea.UnderlayElements; - private GameplaySampleTriggerSource sampleTriggerSource; + private GameplaySampleTriggerSource sampleTriggerSource = null!; /// /// Whether this is a special (ie. scratch) column. @@ -58,6 +58,11 @@ namespace osu.Game.Rulesets.Mania.UI public readonly Bindable AccentColour = new Bindable(Color4.Black); + private IBindable touchOverlay = null!; + + private float leftColumnSpacing; + private float rightColumnSpacing; + public Column(int index, bool isSpecial) { Index = index; @@ -67,14 +72,18 @@ namespace osu.Game.Rulesets.Mania.UI Width = COLUMN_WIDTH; hitPolicy = new OrderedHitPolicy(HitObjectContainer); - HitObjectArea = new ColumnHitObjectArea(HitObjectContainer) { RelativeSizeAxes = Axes.Both }; + HitObjectArea = new ColumnHitObjectArea + { + RelativeSizeAxes = Axes.Both, + Child = HitObjectContainer, + }; } [Resolved] - private ISkinSource skin { get; set; } + private ISkinSource skin { get; set; } = null!; [BackgroundDependencyLoader] - private void load(GameHost host) + private void load(GameHost host, ManiaRulesetConfigManager? rulesetConfig) { SkinnableDrawable keyArea; @@ -112,11 +121,22 @@ namespace osu.Game.Rulesets.Mania.UI RegisterPool(10, 50); RegisterPool(10, 50); RegisterPool(10, 50); + + if (rulesetConfig != null) + touchOverlay = rulesetConfig.GetBindable(ManiaRulesetSetting.TouchOverlay); } private void onSourceChanged() { AccentColour.Value = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour, Index)?.Value ?? Color4.Black; + + leftColumnSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.LeftColumnSpacing, Index)) + ?.Value ?? Stage.COLUMN_SPACING; + + rightColumnSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.RightColumnSpacing, Index)) + ?.Value ?? Stage.COLUMN_SPACING; } protected override void LoadComplete() @@ -132,7 +152,7 @@ namespace osu.Game.Rulesets.Mania.UI base.Dispose(isDisposing); - if (skin != null) + if (skin.IsNotNull()) skin.SourceChanged -= onSourceChanged; } @@ -178,7 +198,38 @@ namespace osu.Game.Rulesets.Mania.UI } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) - // This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border - => DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); + { + // Extend input coverage to the gaps close to this column. + var spacingInflation = new MarginPadding { Left = leftColumnSpacing, Right = rightColumnSpacing }; + return DrawRectangle.Inflate(spacingInflation).Contains(ToLocalSpace(screenSpacePos)); + } + + #region Touch Input + + [Resolved] + private ManiaInputManager? maniaInputManager { get; set; } + + private int touchActivationCount; + + protected override bool OnTouchDown(TouchDownEvent e) + { + // if touch overlay is visible, disallow columns from handling touch directly. + if (touchOverlay.Value) + return false; + + maniaInputManager?.KeyBindingContainer.TriggerPressed(Action.Value); + touchActivationCount++; + return true; + } + + protected override void OnTouchUp(TouchUpEvent e) + { + touchActivationCount--; + + if (touchActivationCount == 0) + maniaInputManager?.KeyBindingContainer.TriggerReleased(Action.Value); + } + + #endregion } } diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index 5614a13a48..03e5791519 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.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. -#nullable disable - +using System; +using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Mania.UI { @@ -34,6 +39,8 @@ namespace osu.Game.Rulesets.Mania.UI set => base.Masking = value; } + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize); + public ColumnFlow(StageDefinition stageDefinition) { this.stageDefinition = stageDefinition; @@ -52,42 +59,32 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < stageDefinition.Columns; i++) columns.Add(new Container { RelativeSizeAxes = Axes.Y }); + + AddLayout(layout); } - private ISkinSource currentSkin; + [Resolved] + private ISkinSource skin { get; set; } = null!; + + private readonly Bindable mobileLayout = new Bindable(); [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load(ManiaRulesetConfigManager? rulesetConfig) { - currentSkin = skin; + rulesetConfig?.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout); - skin.SourceChanged += onSkinChanged; - onSkinChanged(); + mobileLayout.BindValueChanged(_ => invalidateLayout()); + skin.SourceChanged += invalidateLayout; } - private void onSkinChanged() + protected override void Update() { - for (int i = 0; i < stageDefinition.Columns; i++) + base.Update(); + + if (!layout.IsValid) { - if (i > 0) - { - float spacing = currentSkin.GetConfig( - new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnSpacing, i - 1)) - ?.Value ?? Stage.COLUMN_SPACING; - - columns[i].Margin = new MarginPadding { Left = spacing }; - } - - float? width = currentSkin.GetConfig( - new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) - ?.Value; - - bool isSpecialColumn = stageDefinition.IsSpecialColumn(i); - - // only used by default skin (legacy skins get defaults set in LegacyManiaSkinConfiguration) - width ??= isSpecialColumn ? Column.SPECIAL_COLUMN_WIDTH : Column.COLUMN_WIDTH; - - columns[i].Width = width.Value; + updateColumnSize(); + layout.Validate(); } } @@ -101,12 +98,61 @@ namespace osu.Game.Rulesets.Mania.UI Content[column] = columns[column].Child = content; } + private void invalidateLayout() => layout.Invalidate(); + + private void updateColumnSize() + { + float mobileAdjust = 1f; + + if (RuntimeInfo.IsMobile && mobileLayout.Value == ManiaMobileLayout.LandscapeExpandedColumns) + { + // GridContainer+CellContainer containing this stage (gets split up for dual stages). + Vector2? containingCell = this.FindClosestParent()?.Parent?.DrawSize; + + // Will be null in tests. + if (containingCell != null && containingCell.Value.X >= containingCell.Value.Y) + { + float aspectRatio = containingCell.Value.X / containingCell.Value.Y; + + // 2.83 is a mostly arbitrary scale-up (170 / 60, based on original implementation for argon) + mobileAdjust = 2.83f * Math.Min(1, 7f / stageDefinition.Columns); + // 1.92 is a "reference" mobile screen aspect ratio for phones. + // We should scale it back for cases like tablets which aren't so extreme. + mobileAdjust *= aspectRatio / 1.92f; + } + } + + for (int i = 0; i < stageDefinition.Columns; i++) + { + float leftSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.LeftColumnSpacing, i)) + ?.Value ?? Stage.COLUMN_SPACING; + + float rightSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.RightColumnSpacing, i)) + ?.Value ?? Stage.COLUMN_SPACING; + + columns[i].Margin = new MarginPadding { Left = leftSpacing, Right = rightSpacing }; + + float? width = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) + ?.Value; + + bool isSpecialColumn = stageDefinition.IsSpecialColumn(i); + + // only used by default skin (legacy skins get defaults set in LegacyManiaSkinConfiguration) + width ??= isSpecialColumn ? Column.SPECIAL_COLUMN_WIDTH : Column.COLUMN_WIDTH; + + columns[i].Width = width.Value * mobileAdjust; + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (currentSkin != null) - currentSkin.SourceChanged -= onSkinChanged; + if (skin.IsNotNull()) + skin.SourceChanged -= invalidateLayout; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs index 91e0f2c19b..46b6ef86f7 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs @@ -3,13 +3,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI.Components { - public partial class ColumnHitObjectArea : HitObjectArea + public partial class ColumnHitObjectArea : HitPositionPaddedContainer { public readonly Container Explosions; @@ -17,25 +16,29 @@ namespace osu.Game.Rulesets.Mania.UI.Components private readonly Drawable hitTarget; - public ColumnHitObjectArea(HitObjectContainer hitObjectContainer) - : base(hitObjectContainer) + protected override Container Content => content; + + private readonly Container content; + + public ColumnHitObjectArea() { AddRangeInternal(new[] { UnderlayElements = new Container { RelativeSizeAxes = Axes.Both, - Depth = 2, }, hitTarget = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HitTarget), _ => new DefaultHitTarget()) { RelativeSizeAxes = Axes.X, - Depth = 1 + }, + content = new Container + { + RelativeSizeAxes = Axes.Both, }, Explosions = new Container { RelativeSizeAxes = Axes.Both, - Depth = -1, } }); } diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/HitPositionPaddedContainer.cs similarity index 54% rename from osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs rename to osu.Game.Rulesets.Mania/UI/Components/HitPositionPaddedContainer.cs index 2ad6e4f076..72daf4b21d 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/HitPositionPaddedContainer.cs @@ -1,52 +1,38 @@ -// 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; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Skinning; -using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI.Components { - public partial class HitObjectArea : SkinReloadableDrawable + public partial class HitPositionPaddedContainer : Container { protected readonly IBindable Direction = new Bindable(); - public readonly HitObjectContainer HitObjectContainer; - public HitObjectArea(HitObjectContainer hitObjectContainer) - { - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Child = HitObjectContainer = hitObjectContainer - }; - } + [Resolved] + private ISkinSource skin { get; set; } = null!; [BackgroundDependencyLoader] private void load(IScrollingInfo scrollingInfo) { Direction.BindTo(scrollingInfo.Direction); - Direction.BindValueChanged(onDirectionChanged, true); + Direction.BindValueChanged(_ => UpdateHitPosition(), true); + + skin.SourceChanged += onSkinChanged; } - protected override void SkinChanged(ISkinSource skin) - { - base.SkinChanged(skin); - UpdateHitPosition(); - } - - private void onDirectionChanged(ValueChangedEvent direction) - { - UpdateHitPosition(); - } + private void onSkinChanged() => UpdateHitPosition(); protected virtual void UpdateHitPosition() { - float hitPosition = CurrentSkin.GetConfig( + float hitPosition = skin.GetConfig( new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value ?? Stage.HIT_TARGET_POSITION; @@ -54,5 +40,13 @@ namespace osu.Game.Rulesets.Mania.UI.Components ? new MarginPadding { Top = hitPosition } : new MarginPadding { Bottom = hitPosition }; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (skin.IsNotNull()) + skin.SourceChanged -= onSkinChanged; + } } } diff --git a/osu.Game.Rulesets.Mania/UI/DefaultManiaJudgementPiece.cs b/osu.Game.Rulesets.Mania/UI/DefaultManiaJudgementPiece.cs new file mode 100644 index 0000000000..4872fe5049 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/DefaultManiaJudgementPiece.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Mania.UI +{ + public partial class DefaultManiaJudgementPiece : DefaultJudgementPiece + { + private const float judgement_y_position = -180f; + + private IBindable direction = null!; + + public DefaultManiaJudgementPiece(HitResult result) + : base(result) + { + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); + } + + private void onDirectionChanged() + { + Anchor = direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + JudgementText.Font = JudgementText.Font.With(size: 25); + } + + public override void PlayAnimation() + { + switch (Result) + { + case HitResult.None: + this.FadeOutFromOne(800); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveToY(direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + + this.FadeOutFromOne(800); + break; + + default: + this.ScaleTo(0.8f); + this.ScaleTo(1, 250, Easing.OutElastic); + + 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/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index 9f25a44e21..20248ab6bc 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -6,48 +6,22 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Mania.UI { public partial class DrawableManiaJudgement : DrawableJudgement { - protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); - - private partial class DefaultManiaJudgementPiece : DefaultJudgementPiece + public DrawableManiaJudgement() { - public DefaultManiaJudgementPiece(HitResult result) - : base(result) - { - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - JudgementText.Font = JudgementText.Font.With(size: 25); - } - - public override void PlayAnimation() - { - switch (Result) - { - case HitResult.None: - case HitResult.Miss: - base.PlayAnimation(); - break; - - default: - this.ScaleTo(0.8f); - this.ScaleTo(1, 250, Easing.OutElastic); - - this.Delay(50) - .ScaleTo(0.75f, 250) - .FadeOut(200); - - // osu!mania uses a custom fade length, so the base call is intentionally omitted. - break; - } - } + // Extend the dimensions of this drawable to the entire parenting container. + // This allows skin implementations (i.e. LegacyManiaJudgementPiece) to freely choose the anchor based on skin settings. + Anchor = Anchor.TopLeft; + Origin = Anchor.TopLeft; + RelativeSizeAxes = Axes.Both; + Size = new Vector2(1f); } + + protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); } } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index d173ae4143..d9a03d1c30 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -32,7 +32,6 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI { - [Cached] public partial class DrawableManiaRuleset : DrawableScrollingRuleset { /// @@ -51,15 +50,20 @@ namespace osu.Game.Rulesets.Mania.UI public IEnumerable BarLines; + public override bool RequiresPortraitOrientation => Beatmap.Stages.Count == 1 && mobileLayout.Value == ManiaMobileLayout.Portrait; + protected override bool RelativeScaleBeatLengths => true; protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; private readonly Bindable configDirection = new Bindable(); private readonly BindableDouble configScrollSpeed = new BindableDouble(); + private readonly Bindable mobileLayout = new Bindable(); + private readonly Bindable touchOverlay = new Bindable(); + + public double TargetTimeRange { get; protected set; } private double currentTimeRange; - protected double TargetTimeRange; // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); @@ -107,11 +111,36 @@ namespace osu.Game.Rulesets.Mania.UI configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed); - configScrollSpeed.BindValueChanged(speed => TargetTimeRange = ComputeScrollTime(speed.NewValue)); + configScrollSpeed.BindValueChanged(speed => + { + if (!AllowScrollSpeedAdjustment) + return; + + TargetTimeRange = ComputeScrollTime(speed.NewValue); + }); TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value); - KeyBindingInputManager.Add(new ManiaTouchInputArea()); + Config.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout); + mobileLayout.BindValueChanged(_ => updateMobileLayout(), true); + + Config.BindWith(ManiaRulesetSetting.TouchOverlay, touchOverlay); + touchOverlay.BindValueChanged(_ => updateMobileLayout(), true); + } + + private ManiaTouchInputArea? touchInputArea; + + private void updateMobileLayout() + { + if (touchOverlay.Value) + KeyBindingInputManager.Add(touchInputArea = new ManiaTouchInputArea(this)); + else + { + if (touchInputArea != null) + KeyBindingInputManager.Remove(touchInputArea, true); + + touchInputArea = null; + } } protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount; @@ -162,7 +191,7 @@ namespace osu.Game.Rulesets.Mania.UI /// The scroll time. public static double ComputeScrollTime(double scrollSpeed) => MAX_TIME_RANGE / scrollSpeed; - public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(); + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(this); protected override Playfield CreatePlayfield() => new ManiaPlayfield(Beatmap.Stages); diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs index 1183b616f5..feb75b9f1e 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs @@ -1,17 +1,63 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.UI; +using osuTK; namespace osu.Game.Rulesets.Mania.UI { public partial class ManiaPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { - public ManiaPlayfieldAdjustmentContainer() + protected override Container Content { get; } + + private readonly DrawSizePreservingFillContainer scalingContainer; + + private readonly DrawableManiaRuleset drawableManiaRuleset; + + public ManiaPlayfieldAdjustmentContainer(DrawableManiaRuleset drawableManiaRuleset) { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; + this.drawableManiaRuleset = drawableManiaRuleset; + InternalChild = scalingContainer = new DrawSizePreservingFillContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Child = Content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + protected override void Update() + { + base.Update(); + + float aspectRatio = DrawWidth / DrawHeight; + bool isPortrait = aspectRatio < 1f; + + if (isPortrait && drawableManiaRuleset.Beatmap.Stages.Count == 1) + { + // Scale playfield up by 25% to become playable on mobile devices, + // and leave a 10% horizontal gap if the playfield is scaled down due to being too wide. + const float base_scale = 1.25f; + const float base_width = 768f / base_scale; + const float side_gap = 0.9f; + + scalingContainer.Strategy = DrawSizePreservationStrategy.Maximum; + float stageWidth = drawableManiaRuleset.Playfield.Stages[0].DrawWidth; + scalingContainer.TargetDrawSize = new Vector2(1024, base_width * Math.Max(stageWidth / aspectRatio / (base_width * side_gap), 1f)); + } + else + { + scalingContainer.Strategy = DrawSizePreservationStrategy.Minimum; + scalingContainer.Scale = new Vector2(1f); + scalingContainer.Size = new Vector2(1f); + scalingContainer.TargetDrawSize = new Vector2(1024, 768); + } } } } diff --git a/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs index 8c4a71cf24..7c5f759833 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs @@ -18,6 +18,8 @@ namespace osu.Game.Rulesets.Mania.UI /// public partial class ManiaTouchInputArea : VisibilityContainer { + private readonly DrawableManiaRuleset drawableRuleset; + // visibility state affects our child. we always want to handle input. public override bool PropagatePositionalInputSubTree => true; public override bool PropagateNonPositionalInputSubTree => true; @@ -38,13 +40,12 @@ namespace osu.Game.Rulesets.Mania.UI MaxValue = 1 }; - [Resolved] - private DrawableManiaRuleset drawableRuleset { get; set; } = null!; - private GridContainer gridContainer = null!; - public ManiaTouchInputArea() + public ManiaTouchInputArea(DrawableManiaRuleset drawableRuleset) { + this.drawableRuleset = drawableRuleset; + Anchor = Anchor.BottomCentre; Origin = Anchor.BottomCentre; @@ -70,7 +71,11 @@ namespace osu.Game.Rulesets.Mania.UI receptorGridDimensions.Add(new Dimension(GridSizeMode.AutoSize)); } - receptorGridContent.Add(new ColumnInputReceptor { Action = { BindTarget = column.Action } }); + receptorGridContent.Add(new ColumnInputReceptor + { + Action = { BindTarget = column.Action }, + Spacing = { BindTarget = Spacing }, + }); receptorGridDimensions.Add(new Dimension()); first = false; @@ -118,6 +123,7 @@ namespace osu.Game.Rulesets.Mania.UI public partial class ColumnInputReceptor : CompositeDrawable { public readonly IBindable Action = new Bindable(); + public readonly IBindable Spacing = new BindableFloat(); private readonly Box highlightOverlay; @@ -155,6 +161,10 @@ namespace osu.Game.Rulesets.Mania.UI }; } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + // Extend input coverage to the gaps close to this receptor. + => DrawRectangle.Inflate(new Vector2(Spacing.Value / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); + protected override bool OnTouchDown(TouchDownEvent e) { updateButton(true); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index 9fb77a4995..faa9fc318c 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -103,12 +103,13 @@ namespace osu.Game.Rulesets.Mania.UI Width = 1366, // Bar lines should only be masked on the vertical axis BypassAutoSizeAxes = Axes.Both, Masking = true, - Child = barLineContainer = new HitObjectArea(HitObjectContainer) + Child = barLineContainer = new HitPositionPaddedContainer { Name = "Bar lines", Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.Y, + Child = HitObjectContainer, } }, columnFlow = new ColumnFlow(definition) @@ -119,12 +120,13 @@ namespace osu.Game.Rulesets.Mania.UI { RelativeSizeAxes = Axes.Both }, - judgements = new JudgementContainer + new HitPositionPaddedContainer { - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Y = HIT_TARGET_POSITION + 150 + Child = judgements = new JudgementContainer + { + RelativeSizeAxes = Axes.Both, + }, }, topLevelContainer = new Container { RelativeSizeAxes = Axes.Both } } @@ -214,13 +216,7 @@ namespace osu.Game.Rulesets.Mania.UI return; judgements.Clear(false); - judgements.Add(judgementPooler.Get(result.Type, j => - { - j.Apply(result, judgedObject); - - j.Anchor = Anchor.Centre; - j.Origin = Anchor.Centre; - })!); + judgements.Add(judgementPooler.Get(result.Type, j => j.Apply(result, judgedObject))!); } protected override void Update() diff --git a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs index 2195c9e1b9..5e4da2d480 100644 --- a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs +++ b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs @@ -42,13 +42,22 @@ namespace osu.Game.Rulesets.Mania var bindings = new List(); for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++) - bindings.Add(new KeyBinding(LeftKeys[i], currentAction++)); + { + bindings.Add(new KeyBinding(LeftKeys[i], currentAction)); + bindings.Add(new KeyBinding(InputKey.None, currentAction++)); + } if (columns % 2 == 1) - bindings.Add(new KeyBinding(SpecialKey, currentAction++)); + { + bindings.Add(new KeyBinding(SpecialKey, currentAction)); + bindings.Add(new KeyBinding(InputKey.None, currentAction++)); + } for (int i = 0; i < columns / 2; i++) - bindings.Add(new KeyBinding(RightKeys[i], currentAction++)); + { + bindings.Add(new KeyBinding(RightKeys[i], currentAction)); + bindings.Add(new KeyBinding(InputKey.None, currentAction++)); + } return bindings; } diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/AppDelegate.cs b/osu.Game.Rulesets.Osu.Tests.iOS/AppDelegate.cs new file mode 100644 index 0000000000..77177e93f1 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests.iOS/AppDelegate.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 Foundation; +using osu.Framework.iOS; +using osu.Game.Tests; + +namespace osu.Game.Rulesets.Osu.Tests.iOS +{ + [Register("AppDelegate")] + public class AppDelegate : GameApplicationDelegate + { + protected override Framework.Game CreateGame() => new OsuTestBrowser(); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs b/osu.Game.Rulesets.Osu.Tests.iOS/Program.cs similarity index 66% rename from osu.Game.Rulesets.Osu.Tests.iOS/Application.cs rename to osu.Game.Rulesets.Osu.Tests.iOS/Program.cs index f9059014a5..579e20e05a 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Program.cs @@ -1,16 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.iOS; -using osu.Game.Tests; +using UIKit; namespace osu.Game.Rulesets.Osu.Tests.iOS { - public static class Application + public static class Program { public static void Main(string[] args) { - GameApplication.Main(new OsuTestBrowser()); + UIApplication.Main(args, null, typeof(AppDelegate)); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs index a105d860bf..5bce97d7b8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs @@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { public partial class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene { + protected sealed override Ruleset CreateRuleset() => new OsuRuleset(); protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHitCircle((HitCircle)hitObject); protected override HitObjectPlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint(); } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs index 345965b912..4e6cad1dca 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs @@ -10,7 +10,9 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; @@ -261,6 +263,163 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("selection preserved", () => EditorBeatmap.SelectedHitObjects.Count, () => Is.EqualTo(1)); } + [Test] + public void TestQuickDeleteOnUnselectedControlPointOnlyRemovesThatControlPoint() + { + var slider = new Slider + { + StartTime = 0, + Position = new Vector2(100, 100), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint { Type = PathType.LINEAR }, + new PathControlPoint(new Vector2(100, 0)), + new PathControlPoint(new Vector2(100)), + new PathControlPoint(new Vector2(0, 100)) + } + } + }; + AddStep("add slider", () => EditorBeatmap.Add(slider)); + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + + AddStep("select second node", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddStep("also select third node", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(2)); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddStep("quick-delete fourth node", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(3)); + InputManager.Click(MouseButton.Middle); + }); + AddUntilStep("slider not deleted", () => EditorBeatmap.HitObjects.OfType().Count(), () => Is.EqualTo(1)); + AddUntilStep("slider path has 3 nodes", () => EditorBeatmap.HitObjects.OfType().Single().Path.ControlPoints.Count, () => Is.EqualTo(3)); + } + + [Test] + public void TestQuickDeleteOnSelectedControlPointRemovesEntireSelection() + { + var slider = new Slider + { + StartTime = 0, + Position = new Vector2(100, 100), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint { Type = PathType.LINEAR }, + new PathControlPoint(new Vector2(100, 0)), + new PathControlPoint(new Vector2(100)), + new PathControlPoint(new Vector2(0, 100)) + } + } + }; + AddStep("add slider", () => EditorBeatmap.Add(slider)); + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + + AddStep("select second node", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddStep("also select third node", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(2)); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddStep("quick-delete second node", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1)); + InputManager.Click(MouseButton.Middle); + }); + AddUntilStep("slider not deleted", () => EditorBeatmap.HitObjects.OfType().Count(), () => Is.EqualTo(1)); + AddUntilStep("slider path has 2 nodes", () => EditorBeatmap.HitObjects.OfType().Single().Path.ControlPoints.Count, () => Is.EqualTo(2)); + } + + [Test] + public void TestSliderDragMarkerDoesNotBlockControlPointContextMenu() + { + var slider = new Slider + { + StartTime = 0, + Position = new Vector2(100, 100), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint { Type = PathType.LINEAR }, + new PathControlPoint(new Vector2(50, 100)), + new PathControlPoint(new Vector2(145, 100)), + }, + ExpectedDistance = { Value = 162.62 } + }, + }; + AddStep("add slider", () => EditorBeatmap.Add(slider)); + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + + AddStep("select last node", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType>().Last()); + InputManager.Click(MouseButton.Left); + }); + AddStep("right click node", () => InputManager.Click(MouseButton.Right)); + AddUntilStep("context menu open", () => this.ChildrenOfType().Single().ChildrenOfType().All(m => m.State == MenuState.Open)); + } + + [Test] + public void TestSliderDragMarkerBlocksSelectionOfObjectsUnderneath() + { + var firstSlider = new Slider + { + StartTime = 0, + Position = new Vector2(10, 50), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100)) + } + } + }; + var secondSlider = new Slider + { + StartTime = 500, + Position = new Vector2(200, 0), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(-100, 100)) + } + } + }; + + AddStep("add objects", () => EditorBeatmap.AddRange(new HitObject[] { firstSlider, secondSlider })); + AddStep("select second slider", () => EditorBeatmap.SelectedHitObjects.Add(secondSlider)); + + AddStep("move to marker", () => + { + var marker = this.ChildrenOfType().First(); + var position = (marker.ScreenSpaceDrawQuad.TopRight + marker.ScreenSpaceDrawQuad.BottomRight) / 2; + InputManager.MoveMouseTo(position); + }); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("second slider still selected", () => EditorBeatmap.SelectedHitObjects.Single(), () => Is.EqualTo(secondSlider)); + } + private ComposeBlueprintContainer blueprintContainer => Editor.ChildrenOfType().First(); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs index f257ed5987..c6893a5bdf 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -9,6 +9,7 @@ using osu.Framework.Utils; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Tests.Visual; @@ -284,5 +285,70 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor && Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01); }); } + + [Test] + public void TestGridPlacementCommittedByDragSelection() + { + AddStep("add circle", () => EditorBeatmap.Add(new HitCircle + { + Position = new Vector2(64, 64), + StartTime = EditorClock.CurrentTime, + })); + + AddStep("select circle tool", () => InputManager.Key(Key.Number2)); + AddStep("select grid tool", () => InputManager.Key(Key.Number5)); + AddStep("move cursor to centre", () => InputManager.MoveMouseTo(Editor)); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("move cursor to (-1, -1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(new Vector2(-1, -1))); + }); + AddStep("drag to center", () => + { + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(Editor); + }); + AddStep("release left", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("one selection", () => Editor.ChildrenOfType().Single().SelectedBlueprints, () => Has.One.Items); + AddAssert("selection is circle", () => Editor.ChildrenOfType().Single().SelectedBlueprints.Single(), Is.TypeOf); + + AddStep("move cursor to slider", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.ElementAt(1)).EndPosition + new Vector2(1, 1))); + }); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("one selection", () => Editor.ChildrenOfType().Single().SelectedBlueprints, () => Has.One.Items); + AddAssert("selection is slider", () => Editor.ChildrenOfType().Single().SelectedBlueprints.Single(), Is.TypeOf); + } + + [Test] + public void TestGridPlacementRevertsToLastTool() + { + AddStep("select circle tool", () => InputManager.Key(Key.Number2)); + AddStep("select grid tool", () => InputManager.Key(Key.Number5)); + AddStep("move cursor to centre", () => InputManager.MoveMouseTo(Editor)); + AddStep("start grid placement", () => InputManager.Click(MouseButton.Left)); + AddStep("end grid placement", () => InputManager.Click(MouseButton.Left)); + AddAssert("tool reverted to circle", () => getComposer().BlueprintContainer.CurrentTool, Is.TypeOf); + + HitObjectComposer getComposer() => Editor.ChildrenOfType().Single(); + } + + [Test] + public void TestGridPlacementDoesNotOverrideToolChange() + { + AddStep("select circle tool", () => InputManager.Key(Key.Number2)); + AddStep("select grid tool", () => InputManager.Key(Key.Number5)); + AddStep("move cursor to centre", () => InputManager.MoveMouseTo(Editor)); + AddStep("start grid placement", () => InputManager.Click(MouseButton.Left)); + AddStep("select circle tool again", () => InputManager.Key(Key.Number2)); + AddAssert("circle tool selected", () => getComposer().BlueprintContainer.CurrentTool, Is.TypeOf); + + HitObjectComposer getComposer() => Editor.ChildrenOfType().Single(); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs index 5831cc0a8a..8835254c48 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs @@ -23,6 +23,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { public partial class TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene { + protected sealed override Ruleset CreateRuleset() => new OsuRuleset(); + [SetUp] public void Setup() => Schedule(() => { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs index d7b5cc73be..18834ef847 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs @@ -13,6 +13,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { public partial class TestSceneSpinnerPlacementBlueprint : PlacementBlueprintTestScene { + protected sealed override Ruleset CreateRuleset() => new OsuRuleset(); + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSpinner((Spinner)hitObject); protected override HitObjectPlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint(); diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs index 33ae2c68e6..f4f7f9d44b 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Testing; -using osu.Framework.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; @@ -33,27 +32,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [Test] public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); - [Test] - public void TestPlayfieldBasedSize() - { - ModFlashlight mod = new OsuModFlashlight(); - CreateModTest(new ModTestData - { - Mod = mod, - PassCondition = () => - { - var flashlightOverlay = Player.DrawableRuleset.Overlays - .ChildrenOfType.Flashlight>() - .First(); - - return Precision.AlmostEquals(mod.DefaultFlashlightSize * .5f, flashlightOverlay.GetSize()); - } - }); - - AddStep("adjust playfield scale", () => - Player.DrawableRuleset.Playfield.Scale = new Vector2(.5f)); - } - [Test] public void TestSliderDimsOnlyAfterStartTime() { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRelax.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRelax.cs new file mode 100644 index 0000000000..b4298344b8 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRelax.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Replays; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModRelax : OsuModTestScene + { + protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new ModRelaxTestPlayer(CurrentTestData, AllowFail); + + [Test] + public void TestRelax() => CreateModTest(new ModTestData + { + Mod = new OsuModRelax(), + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + Difficulty = { OverallDifficulty = 9 }, + HitObjects = new List + { + new HitCircle + { + StartTime = 1000, + Position = new Vector2(100, 100), + HitWindows = new OsuHitWindows() + } + } + }, + ReplayFrames = new List + { + new OsuReplayFrame(0, new Vector2()), + new OsuReplayFrame(100, new Vector2(100)), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 1 + }); + + [Test] + public void TestRelaxLeniency() => CreateModTest(new ModTestData + { + Mod = new OsuModRelax(), + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + Difficulty = { OverallDifficulty = 9 }, + HitObjects = new List + { + new HitCircle + { + StartTime = 1000, + Position = new Vector2(100, 100), + HitWindows = new OsuHitWindows() + } + } + }, + ReplayFrames = new List + { + new OsuReplayFrame(0, new Vector2(78, 78)), // must be an edge hit for the cursor to not stay on the object for too long + new OsuReplayFrame(1000 - OsuModRelax.RELAX_LENIENCY, new Vector2(78, 78)), + new OsuReplayFrame(1000, new Vector2(0)), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 1 + }); + + protected partial class ModRelaxTestPlayer : ModTestPlayer + { + private readonly ModTestData currentTestData; + + public ModRelaxTestPlayer(ModTestData data, bool allowFail) + : base(data, allowFail) + { + currentTestData = data; + } + + protected override void PrepareReplay() + { + // We need to set IsLegacyScore to true otherwise the mod assumes that presses are already embedded into the replay + DrawableRuleset?.SetReplayScore(new Score + { + Replay = new Replay { Frames = currentTestData.ReplayFrames! }, + ScoreInfo = new ScoreInfo { User = new APIUser { Username = @"Test" }, IsLegacyScore = true, Mods = new Mod[] { new OsuModRelax() } }, + }); + + DrawableRuleset?.SetRecordTarget(Score); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs index 66a60e3542..170d9830f2 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs @@ -49,5 +49,59 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }, PassCondition = () => Player.ScoreProcessor.Combo.Value == 2 }); + + [Test] + public void TestRewind() + { + bool seekedBack = false; + bool missRecorded = false; + + CreateModTest(new ModTestData + { + Mod = new OsuModStrictTracking(), + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 1000, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(0, 100)) + } + } + } + } + }, + ReplayFrames = new List + { + new OsuReplayFrame(0, new Vector2(100, 0)), + new OsuReplayFrame(1000, new Vector2(100, 0)), + new OsuReplayFrame(1050, new Vector2()), + new OsuReplayFrame(1100, new Vector2(), OsuAction.LeftButton), + new OsuReplayFrame(1750, new Vector2(0, 100), OsuAction.LeftButton), + new OsuReplayFrame(1751, new Vector2(0, 100)), + }, + PassCondition = () => seekedBack && !missRecorded, + }); + AddStep("subscribe to new judgements", () => Player.ScoreProcessor.NewJudgement += j => + { + if (!j.IsHit) + missRecorded = true; + }); + AddUntilStep("wait for gameplay completion", () => Player.GameplayState.HasCompleted); + AddAssert("no misses", () => missRecorded, () => Is.False); + AddStep("seek back", () => + { + Player.GameplayClockContainer.Stop(); + Player.Seek(1040); + seekedBack = true; + }); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs index 688cf70f71..23dd2123c3 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs @@ -24,11 +24,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { } - [Test] - public void TestMissTail() => CreateModTest(new ModTestData + [TestCase(true)] + [TestCase(false)] + public void TestMissTail(bool tailMiss) => CreateModTest(new ModTestData { - Mod = new OsuModSuddenDeath(), - PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(false), + Mod = new OsuModSuddenDeath + { + FailOnSliderTail = { Value = tailMiss } + }, + PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(tailMiss), Autoplay = false, CreateBeatmap = () => new Beatmap { diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index efda3fa369..e7a6d8ecff 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; - [TestCase(6.7171144000821119d, 239, "diffcalc-test")] - [TestCase(1.4485749025771304d, 54, "zero-length-sliders")] - [TestCase(0.42630400627180914d, 4, "very-fast-slider")] - [TestCase(0.14143808967817237d, 2, "nan-slider")] + [TestCase(6.6232533278125061d, 239, "diffcalc-test")] + [TestCase(1.5045783545699611d, 54, "zero-length-sliders")] + [TestCase(0.43333836671191595d, 4, "very-fast-slider")] + [TestCase(0.13841532030395723d, 2, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(8.9825709931204205d, 239, "diffcalc-test")] - [TestCase(1.7550169162648608d, 54, "zero-length-sliders")] - [TestCase(0.55231632896800109d, 4, "very-fast-slider")] + [TestCase(9.6491691624112761d, 239, "diffcalc-test")] + [TestCase(1.756936832498702d, 54, "zero-length-sliders")] + [TestCase(0.57771197086735004d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.7171144000821119d, 239, "diffcalc-test")] - [TestCase(1.4485749025771304d, 54, "zero-length-sliders")] - [TestCase(0.42630400627180914d, 4, "very-fast-slider")] + [TestCase(6.6232533278125061d, 239, "diffcalc-test")] + [TestCase(1.5045783545699611d, 54, "zero-length-sliders")] + [TestCase(0.43333836671191595d, 4, "very-fast-slider")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/OsuRateAdjustedDisplayDifficultyTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuRateAdjustedDisplayDifficultyTest.cs index aa903205c8..fd929dd8f4 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuRateAdjustedDisplayDifficultyTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuRateAdjustedDisplayDifficultyTest.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests { @@ -21,8 +22,9 @@ namespace osu.Game.Rulesets.Osu.Tests { var ruleset = new OsuRuleset(); var difficulty = new BeatmapDifficulty { ApproachRate = originalApproachRate }; + var beatmapInfo = new BeatmapInfo { Difficulty = difficulty }; - var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1); + var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, []); Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(originalApproachRate)); } @@ -32,8 +34,9 @@ namespace osu.Game.Rulesets.Osu.Tests { var ruleset = new OsuRuleset(); var difficulty = new BeatmapDifficulty { OverallDifficulty = originalOverallDifficulty }; + var beatmapInfo = new BeatmapInfo { Difficulty = difficulty }; - var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1); + var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, []); Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(originalOverallDifficulty)); } @@ -43,8 +46,9 @@ namespace osu.Game.Rulesets.Osu.Tests { var ruleset = new OsuRuleset(); var difficulty = new BeatmapDifficulty(); + var beatmapInfo = new BeatmapInfo { Difficulty = difficulty }; - var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 0.75); + var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, [new OsuModHalfTime()]); Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(1.67).Within(0.01)); Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(2.22).Within(0.01)); @@ -55,8 +59,9 @@ namespace osu.Game.Rulesets.Osu.Tests { var ruleset = new OsuRuleset(); var difficulty = new BeatmapDifficulty(); + var beatmapInfo = new BeatmapInfo { Difficulty = difficulty }; - var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1.5); + var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, [new OsuModDoubleTime()]); Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(7.67).Within(0.01)); Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(7.77).Within(0.01)); diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/approachcircle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/approachcircle.png deleted file mode 100644 index ff8b02ce80..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/approachcircle.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png deleted file mode 100644 index 5f7beae4e9..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor.png deleted file mode 100755 index fe305468fe..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursortrail.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursortrail.png deleted file mode 100755 index f3327dc92f..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursortrail.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-0.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-0.png deleted file mode 100644 index 2af0569bcb..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-0.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-1.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-1.png deleted file mode 100644 index e8b674d62a..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-1.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-2.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-2.png deleted file mode 100644 index dbf7bc73bc..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-2.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-3.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-3.png deleted file mode 100644 index 43990c46e7..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-3.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-4.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-4.png deleted file mode 100644 index 4564f6d8bf..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-4.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-5.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-5.png deleted file mode 100644 index dcef35eb59..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-5.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-6.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-6.png deleted file mode 100644 index bfc0a01be5..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-6.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-7.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-7.png deleted file mode 100644 index b9079ad5d5..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-7.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-8.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-8.png deleted file mode 100644 index 3a3d61b947..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-8.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-9.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-9.png deleted file mode 100644 index 3e703cd1cf..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-9.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit0.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit0.png deleted file mode 100644 index 3c3ebbfd0b..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit0.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit100.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit100.png deleted file mode 100644 index 9ecc302910..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit100.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit300.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit300.png deleted file mode 100644 index 24945f7d92..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit300.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit50.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit50.png deleted file mode 100644 index d3f7eec5ee..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit50.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircle.png deleted file mode 100644 index a5a3545abf..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircle.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircleoverlay.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircleoverlay.png deleted file mode 100644 index b4062b8c62..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircleoverlay.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png deleted file mode 100644 index 7ebdec37d3..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-0.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-0.png deleted file mode 100644 index 8304617d8c..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-0.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-1.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-1.png deleted file mode 100644 index c3b85eb873..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-1.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-2.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-2.png deleted file mode 100644 index 7f65eb7ca7..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-2.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-3.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-3.png deleted file mode 100644 index 82bec3babe..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-3.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-4.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-4.png deleted file mode 100644 index 5e38c75a9d..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-4.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-5.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-5.png deleted file mode 100644 index a562d9f2ac..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-5.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-6.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-6.png deleted file mode 100644 index b4cf81f26e..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-6.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-7.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-7.png deleted file mode 100644 index a23f5379b2..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-7.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-8.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-8.png deleted file mode 100644 index 430b18509d..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-8.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-9.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-9.png deleted file mode 100644 index add1202c31..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-9.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-comma.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-comma.png deleted file mode 100644 index f68d32957f..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-comma.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-dot.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-dot.png deleted file mode 100644 index 80c39b8745..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-dot.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-percent.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-percent.png deleted file mode 100644 index fc750abc7e..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-percent.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-x.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-x.png deleted file mode 100644 index 779773f8bd..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-x.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini deleted file mode 100644 index 06dfa6b7be..0000000000 --- a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini +++ /dev/null @@ -1,6 +0,0 @@ -[General] -// no version specified means v1 - -[Fonts] -HitCircleOverlap: 3 -ScoreOverlap: 3 diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png deleted file mode 100644 index 3811e5050f..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png deleted file mode 100644 index d84eab2f15..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png deleted file mode 100644 index 4dd4a6d319..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png deleted file mode 100644 index c66f1c9309..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png deleted file mode 100644 index 33902186d9..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png deleted file mode 100644 index 6882a232e0..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-rpm.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-rpm.png deleted file mode 100644 index 73753554f7..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-rpm.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png deleted file mode 100644 index 98a9991c2f..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav deleted file mode 100644 index 5e583e77aa..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav deleted file mode 100644 index bba19381f1..0000000000 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav and /dev/null differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-clear@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-clear@2x.png new file mode 100644 index 0000000000..8d1ac13c23 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-clear@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-rpm@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-rpm@2x.png new file mode 100644 index 0000000000..544c860972 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-rpm@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-spin@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-spin@2x.png new file mode 100644 index 0000000000..57e602549b Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-spin@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-top@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-top@2x.png new file mode 100644 index 0000000000..aa0eb76b60 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/spinner-top@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAimErrorMeter.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAimErrorMeter.cs new file mode 100644 index 0000000000..65ab6e7e15 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAimErrorMeter.cs @@ -0,0 +1,162 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Scoring; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Utils; +using osu.Framework.Threading; +using osu.Game.Rulesets.Osu.HUD; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneAimErrorMeter : OsuManualInputManagerTestScene + { + private DependencyProvidingContainer dependencyContainer = null!; + private ScoreProcessor scoreProcessor = null!; + + private TestAimErrorMeter aimErrorMeter = null!; + + private CircularContainer gameObject = null!; + + private ScheduledDelegate? automaticAdditionDelegate; + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("Hit marker size", 0f, 12f, 7f, t => + { + if (aimErrorMeter.IsNotNull()) + aimErrorMeter.HitMarkerSize.Value = t; + }); + AddSliderStep("Average position marker size", 1f, 25f, 7f, t => + { + if (aimErrorMeter.IsNotNull()) + aimErrorMeter.AverageMarkerSize.Value = t; + }); + } + + [SetUpSteps] + public void SetupSteps() => AddStep("Create components", () => + { + automaticAdditionDelegate?.Cancel(); + automaticAdditionDelegate = null; + + var ruleset = new OsuRuleset(); + + scoreProcessor = new ScoreProcessor(ruleset); + Child = dependencyContainer = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(ScoreProcessor), scoreProcessor) + } + }; + dependencyContainer.Children = new Drawable[] + { + aimErrorMeter = new TestAimErrorMeter + { + Margin = new MarginPadding + { + Top = 100 + }, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(2), + }, + + gameObject = new CircularContainer + { + Size = new Vector2(2 * OsuHitObject.OBJECT_RADIUS), + Position = new Vector2(256, 192), + Colour = Color4.Yellow, + Masking = true, + BorderThickness = 2, + BorderColour = Color4.White, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(4), + } + } + } + }; + }); + + protected override bool OnMouseDown(MouseDownEvent e) + { + // the division by 2 is because CS=5 applies a 0.5x (plus fudge) multiplier to `OBJECT_RADIUS` + aimErrorMeter.AddPoint((gameObject.ToLocalSpace(e.ScreenSpaceMouseDownPosition) - new Vector2(OsuHitObject.OBJECT_RADIUS)) / 2); + return true; + } + + [Test] + public void TestManyHitPointsAutomatic() + { + AddStep("add scheduled delegate", () => + { + automaticAdditionDelegate = Scheduler.AddDelayed(() => + { + var randomPos = new Vector2( + RNG.NextSingle(0, 2 * OsuHitObject.OBJECT_RADIUS), + RNG.NextSingle(0, 2 * OsuHitObject.OBJECT_RADIUS)); + + aimErrorMeter.AddPoint(randomPos - new Vector2(OsuHitObject.OBJECT_RADIUS)); + InputManager.MoveMouseTo(gameObject.ToScreenSpace(randomPos)); + }, 1, true); + }); + AddWaitStep("wait for some hit points", 10); + } + + [Test] + public void TestDisplayStyles() + { + AddStep("Switch hit position marker style to +", () => aimErrorMeter.HitMarkerStyle.Value = AimErrorMeter.MarkerStyle.Plus); + AddStep("Switch hit position marker style to x", () => aimErrorMeter.HitMarkerStyle.Value = AimErrorMeter.MarkerStyle.X); + AddStep("Switch average position marker style to +", () => aimErrorMeter.AverageMarkerStyle.Value = AimErrorMeter.MarkerStyle.Plus); + AddStep("Switch average position marker style to x", () => aimErrorMeter.AverageMarkerStyle.Value = AimErrorMeter.MarkerStyle.X); + + AddStep("Switch position display to absolute", () => aimErrorMeter.PositionDisplayStyle.Value = AimErrorMeter.PositionDisplay.Absolute); + AddStep("Switch position display to relative", () => aimErrorMeter.PositionDisplayStyle.Value = AimErrorMeter.PositionDisplay.Normalised); + } + + [Test] + public void TestManualPlacement() + { + AddStep("return user input", () => InputManager.UseParentInput = true); + } + + private partial class TestAimErrorMeter : AimErrorMeter + { + public void AddPoint(Vector2 position) + { + OnNewJudgement(new OsuHitCircleJudgementResult(new HitCircle(), new OsuJudgement()) + { + CursorPositionAtHit = position + }); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs index f6e460284b..fd947343e4 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs @@ -110,23 +110,23 @@ namespace osu.Game.Rulesets.Osu.Tests new Spinner { StartTime = 0, - Duration = 1000, + Duration = 3000, Position = OsuPlayfield.BASE_SIZE / 2, }, new Slider { - StartTime = 2500, + StartTime = 4500, RepeatCount = 0, Position = OsuPlayfield.BASE_SIZE / 2, Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), - new PathControlPoint(new Vector2(100, 0)), + new PathControlPoint(new Vector2(200, 0)), }) }, new HitCircle { - StartTime = 4500, + StartTime = 10000, Position = OsuPlayfield.BASE_SIZE / 2, }, }, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index 17f365f820..a8a65f7edb 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; @@ -17,6 +18,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Framework.Testing.Input; using osu.Game.Audio; +using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Skinning; @@ -103,6 +105,23 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("contract", () => this.ChildrenOfType().Single().NewPartScale = Vector2.One); } + [Test] + public void TestRotation() + { + createTest(() => + { + var skinContainer = new LegacySkinContainer(renderer, provideMiddle: true, enableRotation: true); + var legacyCursorTrail = new LegacyRotatingCursorTrail(skinContainer) + { + NewPartScale = new Vector2(10) + }; + + skinContainer.Child = legacyCursorTrail; + + return skinContainer; + }); + } + private void createTest(Func createContent) => AddStep("create trail", () => { Clear(); @@ -121,12 +140,14 @@ namespace osu.Game.Rulesets.Osu.Tests private readonly IRenderer renderer; private readonly bool provideMiddle; private readonly bool provideCursor; + private readonly bool enableRotation; - public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true) + public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true, bool enableRotation = false) { this.renderer = renderer; this.provideMiddle = provideMiddle; this.provideCursor = provideCursor; + this.enableRotation = enableRotation; RelativeSizeAxes = Axes.Both; } @@ -152,7 +173,19 @@ namespace osu.Game.Rulesets.Osu.Tests public ISample GetSample(ISampleInfo sampleInfo) => null; - public IBindable GetConfig(TLookup lookup) => null; + public IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case OsuSkinConfiguration osuLookup: + if (osuLookup == OsuSkinConfiguration.CursorTrailRotate) + return SkinUtils.As(new BindableBool(enableRotation)); + + break; + } + + return null; + } public ISkin FindProvider(Func lookupFunction) => lookupFunction(this) ? this : null; @@ -185,5 +218,19 @@ namespace osu.Game.Rulesets.Osu.Tests MoveMouseTo(ToScreenSpace(DrawSize / 2 + DrawSize / 3 * rPos)); } } + + private partial class LegacyRotatingCursorTrail : LegacyCursorTrail + { + public LegacyRotatingCursorTrail([NotNull] ISkin skin) + : base(skin) + { + } + + protected override void Update() + { + base.Update(); + PartRotation += (float)(Time.Elapsed * 0.1); + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgementSliderTicks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgementSliderTicks.cs new file mode 100644 index 0000000000..5843a9233c --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgementSliderTicks.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +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.Graphics.Sprites; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneDrawableJudgementSliderTicks : OsuSkinnableTestScene + { + private bool classic; + private readonly JudgementPooler[] judgementPools; + + public TestSceneDrawableJudgementSliderTicks() + { + judgementPools = new JudgementPooler[Rows * Cols]; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + int cellIndex = 0; + + SetContents(_ => + { + var container = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + judgementPools[cellIndex] = new JudgementPooler(new[] + { + HitResult.Great, + HitResult.Miss, + HitResult.LargeTickHit, + HitResult.SliderTailHit, + HitResult.LargeTickMiss, + HitResult.IgnoreMiss, + }), + new GridContainer + { + Padding = new MarginPadding { Top = 26f }, + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = + new[] + { + new[] + { + Empty(), + new OsuSpriteText + { + Text = "hit", + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + new OsuSpriteText + { + Text = "miss", + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + }, + }.Concat(new[] + { + "head", + "tick", + "repeat", + "tail", + "slider", + }.Select(label => new Drawable[] + { + new OsuSpriteText + { + Text = label, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + new Container { RelativeSizeAxes = Axes.Both }, + new Container { RelativeSizeAxes = Axes.Both }, + })).ToArray(), + }, + }, + }; + + cellIndex++; + + return container; + }); + + AddToggleStep("Toggle classic behaviour", c => classic = c); + + AddStep("Show judgements", createAllJudgements); + } + + private void createAllJudgements() + { + for (int cellIndex = 0; cellIndex < Rows * Cols; cellIndex++) + { + var slider = new Slider { StartTime = Time.Current, ClassicSliderBehaviour = classic }; + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + var drawableHitObjects = new DrawableOsuHitObject[] + { + new DrawableSliderHead(new SliderHeadCircle { StartTime = Time.Current, ClassicSliderBehaviour = classic }), + new DrawableSliderTick(new SliderTick { StartTime = Time.Current }), + new DrawableSliderRepeat(new SliderRepeat(slider) { StartTime = Time.Current }), + new DrawableSliderTail(new SliderTailCircle(slider) { StartTime = Time.Current, ClassicSliderBehaviour = classic }), + new DrawableSlider(slider), + }; + + var containers = Cell(cellIndex).ChildrenOfType>().ToArray(); + + for (int i = 0; i < drawableHitObjects.Length; i++) + { + createJudgement(judgementPools[cellIndex], containers[i * 2], drawableHitObjects[i], true); + createJudgement(judgementPools[cellIndex], containers[i * 2 + 1], drawableHitObjects[i], false); + } + } + } + + private void createJudgement(JudgementPooler pool, Container container, DrawableOsuHitObject drawableHitObject, bool hit) + { + container.Clear(false); + + if (!drawableHitObject.DisplayResult) + return; + + var hitObject = drawableHitObject.HitObject; + var result = new OsuJudgementResult(hitObject, hitObject.Judgement) + { + Type = hit ? hitObject.Judgement.MaxResult : hitObject.Judgement.MinResult, + }; + + var judgement = pool.Get(result.Type, d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + d.Scale = new Vector2(0.7f); + d.Apply(result, null); + }); + + if (judgement != null) + container.Add(judgement); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs index e460da9bd5..0e37142940 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs @@ -475,7 +475,7 @@ namespace osu.Game.Rulesets.Osu.Tests performTest(hitObjects, new List { new OsuReplayFrame { Time = time_stack_start - 450, Position = new Vector2(55), Actions = { OsuAction.LeftButton } }, - }); + }, extraMods: [new OsuModNoFail()]); addClickActionAssert(0, ClickAction.Ignore); } @@ -759,9 +759,9 @@ namespace osu.Game.Rulesets.Osu.Tests BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo, - BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION // for correct offset treatment by score encoder }, - ControlPointInfo = cpi + ControlPointInfo = cpi, + BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION // for correct offset treatment by score encoder }); playableBeatmap = Beatmap.Value.GetPlayableBeatmap(new OsuRuleset().RulesetInfo); }); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs new file mode 100644 index 0000000000..404ca0c79e --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs @@ -0,0 +1,247 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene + { + protected override Ruleset CreateRuleset() => new OsuRuleset(); + + protected override string? ExportLocation => null; + + private static readonly object[][] no_mod_test_cases = + { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + // Additionally, note that offsets provided in double will be rounded to the nearest integer. + + // OD = 5 test cases. + // GREAT hit window is ( -50ms, 50ms) + // OK hit window is (-100ms, 100ms) + // MEH hit window is (-150ms, 150ms) + new object[] { 5f, 48d, HitResult.Great }, + new object[] { 5f, 49d, HitResult.Great }, + new object[] { 5f, 50d, HitResult.Ok }, + new object[] { 5f, 51d, HitResult.Ok }, + new object[] { 5f, 98d, HitResult.Ok }, + new object[] { 5f, 99d, HitResult.Ok }, + new object[] { 5f, 100d, HitResult.Meh }, + new object[] { 5f, 101d, HitResult.Meh }, + new object[] { 5f, 148d, HitResult.Meh }, + new object[] { 5f, 149d, HitResult.Meh }, + new object[] { 5f, 150d, HitResult.Miss }, + new object[] { 5f, 151d, HitResult.Miss }, + + // OD = 5.7 test cases. + // GREAT hit window is ( -45ms, 45ms) + // OK hit window is ( -94ms, 94ms) + // MEH hit window is (-143ms, 143ms) + new object[] { 5.7f, 43d, HitResult.Great }, + new object[] { 5.7f, 44d, HitResult.Great }, + new object[] { 5.7f, 45d, HitResult.Ok }, + new object[] { 5.7f, 46d, HitResult.Ok }, + new object[] { 5.7f, 92d, HitResult.Ok }, + new object[] { 5.7f, 93d, HitResult.Ok }, + new object[] { 5.7f, 94d, HitResult.Meh }, + new object[] { 5.7f, 95d, HitResult.Meh }, + new object[] { 5.7f, 141d, HitResult.Meh }, + new object[] { 5.7f, 142d, HitResult.Meh }, + new object[] { 5.7f, 143d, HitResult.Miss }, + new object[] { 5.7f, 144d, HitResult.Miss }, + }; + + private static readonly object[][] hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // GREAT hit window is ( -38ms, 38ms) + // OK hit window is ( -84ms, 84ms) + // MEH hit window is (-130ms, 130ms) + new object[] { 5f, 36d, HitResult.Great }, + new object[] { 5f, 37d, HitResult.Great }, + new object[] { 5f, 38d, HitResult.Ok }, + new object[] { 5f, 39d, HitResult.Ok }, + new object[] { 5f, 82d, HitResult.Ok }, + new object[] { 5f, 83d, HitResult.Ok }, + new object[] { 5f, 84d, HitResult.Meh }, + new object[] { 5f, 85d, HitResult.Meh }, + new object[] { 5f, 128d, HitResult.Meh }, + new object[] { 5f, 129d, HitResult.Meh }, + new object[] { 5f, 130d, HitResult.Miss }, + new object[] { 5f, 131d, HitResult.Miss }, + + // OD = 8 test cases. + // This would lead to "effective" OD of 11.2, + // but the effects are capped to OD 10. + // GREAT hit window is ( -20ms, 20ms) + // OK hit window is ( -60ms, 60ms) + // MEH hit window is (-100ms, 100ms) + new object[] { 8f, 18d, HitResult.Great }, + new object[] { 8f, 19d, HitResult.Great }, + new object[] { 8f, 20d, HitResult.Ok }, + new object[] { 8f, 21d, HitResult.Ok }, + new object[] { 8f, 58d, HitResult.Ok }, + new object[] { 8f, 59d, HitResult.Ok }, + new object[] { 8f, 60d, HitResult.Meh }, + new object[] { 8f, 61d, HitResult.Meh }, + new object[] { 8f, 98d, HitResult.Meh }, + new object[] { 8f, 99d, HitResult.Meh }, + new object[] { 8f, 100d, HitResult.Miss }, + new object[] { 8f, 101d, HitResult.Miss }, + }; + + private static readonly object[][] easy_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 2.5. + // GREAT hit window is ( -65ms, 65ms) + // OK hit window is (-120ms, 120ms) + // MEH hit window is (-175ms, 175ms) + new object[] { 5f, 63d, HitResult.Great }, + new object[] { 5f, 64d, HitResult.Great }, + new object[] { 5f, 65d, HitResult.Ok }, + new object[] { 5f, 66d, HitResult.Ok }, + new object[] { 5f, 118d, HitResult.Ok }, + new object[] { 5f, 119d, HitResult.Ok }, + new object[] { 5f, 120d, HitResult.Meh }, + new object[] { 5f, 121d, HitResult.Meh }, + new object[] { 5f, 173d, HitResult.Meh }, + new object[] { 5f, 174d, HitResult.Meh }, + new object[] { 5f, 175d, HitResult.Miss }, + new object[] { 5f, 176d, HitResult.Miss }, + }; + + private const double hit_circle_time = 100; + + [TestCaseSource(nameof(no_mod_test_cases))] + public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + } + }; + + RunTest($@"single circle @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(hard_rock_test_cases))] + public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new OsuModHardRock()] + } + }; + + RunTest($@"HR single circle @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(easy_test_cases))] + public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new OsuModEasy()] + } + }; + + RunTest($@"EZ single circle @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static OsuBeatmap createBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new OsuBeatmap + { + HitObjects = + { + new HitCircle + { + StartTime = hit_circle_time, + Position = OsuPlayfield.BASE_SIZE / 2 + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs index 184938ceda..06ab6e496f 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Game.Replays; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Replays; @@ -26,6 +27,8 @@ namespace osu.Game.Rulesets.Osu.Tests [Cached] private OsuRulesetConfigManager config = new OsuRulesetConfigManager(null, new OsuRuleset().RulesetInfo); + private readonly StopwatchClock clock = new StopwatchClock(); + [SetUpSteps] public void SetUpSteps() { @@ -35,7 +38,10 @@ namespace osu.Game.Rulesets.Osu.Tests { new OsuPlayfieldAdjustmentContainer { - Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay()), + Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay()) + { + Clock = new FramedClock(clock) + }, }, settings = new ReplayAnalysisSettings(config), }; @@ -55,11 +61,23 @@ namespace osu.Game.Rulesets.Osu.Tests settings.ShowAimMarkers.Value = true; settings.ShowCursorPath.Value = true; }); + AddToggleStep("toggle pause", running => + { + if (running) + clock.Stop(); + else + clock.Start(); + }); } [Test] public void TestHitMarkers() { + AddStep("stop at 2000", () => + { + clock.Stop(); + clock.Seek(2000); + }); AddStep("enable hit markers", () => settings.ShowClickMarkers.Value = true); AddUntilStep("hit markers visible", () => analysisContainer.HitMarkersVisible); AddStep("disable hit markers", () => settings.ShowClickMarkers.Value = false); @@ -69,6 +87,11 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestAimMarker() { + AddStep("stop at 2000", () => + { + clock.Stop(); + clock.Seek(2000); + }); AddStep("enable aim markers", () => settings.ShowAimMarkers.Value = true); AddUntilStep("aim markers visible", () => analysisContainer.AimMarkersVisible); AddStep("disable aim markers", () => settings.ShowAimMarkers.Value = false); @@ -78,6 +101,11 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestAimLines() { + AddStep("stop at 2000", () => + { + clock.Stop(); + clock.Seek(2000); + }); AddStep("enable aim lines", () => settings.ShowCursorPath.Value = true); AddUntilStep("aim lines visible", () => analysisContainer.AimLinesVisible); AddStep("disable aim lines", () => settings.ShowCursorPath.Value = false); @@ -87,7 +115,7 @@ namespace osu.Game.Rulesets.Osu.Tests private Replay fabricateReplay() { var frames = new List(); - var random = new Random(); + var random = new Random(20250522); int posX = 250; int posY = 250; @@ -109,7 +137,7 @@ namespace osu.Game.Rulesets.Osu.Tests frames.Add(new OsuReplayFrame { - Time = Time.Current + i * 15, + Time = i * 15, Position = new Vector2(posX, posY), Actions = actions.ToList(), }); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs index 61cc10f284..ddea5eed87 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs @@ -14,26 +14,27 @@ namespace osu.Game.Rulesets.Osu.Tests protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneOsuHitObjectSamples))); - [TestCase("normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + [TestCase("normal-hitnormal2", "normal-hitnormal")] + [TestCase("hitnormal", "hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(expectedSample, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("osu-hitobject-beatmap-custom-sample-bank.osu"); - AssertBeatmapLookup(expectedSample); + AssertBeatmapLookup(beatmapSkinSampleName); } - [TestCase("normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + [TestCase("", "normal-hitnormal")] + [TestCase("normal-hitnormal", "normal-hitnormal")] + [TestCase("", "hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(string.Empty, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("osu-hitobject-beatmap-custom-sample-bank.osu"); - AssertUserLookup(expectedSample); + AssertUserLookup(userSkinSampleName); } [TestCase("normal-hitnormal2")] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneReplayRecording.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayRecording.cs new file mode 100644 index 0000000000..8608ea1dfc --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayRecording.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneReplayRecording : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + [Resolved] + private AudioManager audioManager { get; set; } = null!; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = + { + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 0, + }, + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 5000, + }, + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 10000, + }, + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 15000, + } + } + }; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => + new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + [Test] + public void TestRecording() + { + seekTo(0); + AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single())); + AddStep("press X", () => InputManager.PressKey(Key.X)); + seekTo(15); + AddStep("release X", () => InputManager.ReleaseKey(Key.X)); + AddAssert("right button press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([OsuAction.RightButton]))); + + seekTo(5000); + AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single())); + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + seekTo(5015); + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + AddAssert("left button press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([OsuAction.LeftButton]))); + + seekTo(10000); + AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single())); + AddStep("press C", () => InputManager.PressKey(Key.C)); + seekTo(10015); + AddStep("release C", () => InputManager.ReleaseKey(Key.C)); + AddAssert("smoke button press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([OsuAction.Smoke]))); + } + + [Test] + public void TestPressAndReleaseOnSameFrame() + { + seekTo(0); + AddStep("move cursor to circle", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.Single())); + AddStep("press X", () => InputManager.PressKey(Key.X)); + AddStep("release X", () => InputManager.ReleaseKey(Key.X)); + AddAssert("right button press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([OsuAction.RightButton]))); + } + + private void seekTo(double time) + { + AddStep($"seek to {time}ms", () => Player.GameplayClockContainer.Seek(time)); + AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(500)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.cs new file mode 100644 index 0000000000..320fdcff2c --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneReplayStability.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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneReplayStability : ReplayStabilityTestScene + { + private static readonly object[][] test_cases = + { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + + // OD = 5 test cases. + // GREAT hit window is [ -49.5ms, 49.5ms] + // OK hit window is [ -99.5ms, 99.5ms] + // MEH hit window is [-149.5ms, 149.5ms] + new object[] { 5f, 49d, HitResult.Great }, + new object[] { 5f, 49.2d, HitResult.Great }, + new object[] { 5f, 49.7d, HitResult.Ok }, + new object[] { 5f, 50d, HitResult.Ok }, + new object[] { 5f, 50.4d, HitResult.Ok }, + new object[] { 5f, 50.9d, HitResult.Ok }, + new object[] { 5f, 51d, HitResult.Ok }, + new object[] { 5f, 99d, HitResult.Ok }, + new object[] { 5f, 99.2d, HitResult.Ok }, + new object[] { 5f, 99.7d, HitResult.Meh }, + new object[] { 5f, 100d, HitResult.Meh }, + new object[] { 5f, 100.4d, HitResult.Meh }, + new object[] { 5f, 100.9d, HitResult.Meh }, + new object[] { 5f, 101d, HitResult.Meh }, + new object[] { 5f, 149d, HitResult.Meh }, + new object[] { 5f, 149.2d, HitResult.Meh }, + new object[] { 5f, 149.7d, HitResult.Miss }, + new object[] { 5f, 150d, HitResult.Miss }, + new object[] { 5f, 150.4d, HitResult.Miss }, + new object[] { 5f, 150.9d, HitResult.Miss }, + new object[] { 5f, 151d, HitResult.Miss }, + + // OD = 5.7 test cases. + // GREAT hit window is [ -44.5ms, 44.5ms] + // OK hit window is [ -93.5ms, 93.5ms] + // MEH hit window is [-142.5ms, 142.5ms] + new object[] { 5.7f, 44d, HitResult.Great }, + new object[] { 5.7f, 44.2d, HitResult.Great }, + new object[] { 5.7f, 44.8d, HitResult.Ok }, + new object[] { 5.7f, 45d, HitResult.Ok }, + new object[] { 5.7f, 45.4d, HitResult.Ok }, + new object[] { 5.7f, 93d, HitResult.Ok }, + new object[] { 5.7f, 93.4d, HitResult.Ok }, + new object[] { 5.7f, 93.9d, HitResult.Meh }, + new object[] { 5.7f, 94d, HitResult.Meh }, + new object[] { 5.7f, 94.4d, HitResult.Meh }, + new object[] { 5.7f, 142d, HitResult.Meh }, + new object[] { 5.7f, 142.2d, HitResult.Meh }, + new object[] { 5.7f, 142.7d, HitResult.Miss }, + new object[] { 5.7f, 143d, HitResult.Miss }, + new object[] { 5.7f, 143.4d, HitResult.Miss }, + new object[] { 5.7f, 143.9d, HitResult.Miss }, + new object[] { 5.7f, 144d, HitResult.Miss }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestHitWindowStability(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + const double hit_circle_time = 100; + + var beatmap = new OsuBeatmap + { + HitObjects = + { + new HitCircle + { + StartTime = hit_circle_time, + Position = OsuPlayfield.BASE_SIZE / 2 + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo, + }, + }; + + var replay = new Replay + { + Frames = + { + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + RunTest(beatmap, replay, [expectedResult]); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 286e4bd775..0842f8f14f 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -484,6 +484,47 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("no miss judgements recorded", () => judgementResults.All(r => r.Type.IsHit())); } + /// + /// Sliders are common to by 1/2 or 1/4 beat length in order to place the circle on the next beat. + /// This tests a user pressing the next circle in the window between the last tick and the end of the slider (). + /// + [Test] + public void TestHitNextCircleDuringTailLeniency() + { + const double bpm = 240; + const double beat_length = 60000 / bpm; + const double slider_start = time_slider_start; + const double slider_end = slider_start + beat_length; + const double last_tick_time = slider_end + SliderEventGenerator.TAIL_LENIENCY; + const double next_circle_time = slider_end + beat_length / 4; + + performTest(new List + { + new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = new Vector2(140, 0), Actions = { OsuAction.RightButton }, Time = last_tick_time + 20 }, + }, + [ + new Slider + { + StartTime = slider_start, + Position = new Vector2(0, 0), + TickDistanceMultiplier = 10, // no ticks + Path = new SliderPath(PathType.PERFECT_CURVE, new[] + { + Vector2.Zero, + new Vector2(100, 0), + }, 100), + }, + new HitCircle + { + StartTime = next_circle_time, + Position = new Vector2(140, 0) + } + ], bpm: bpm); + + AddAssert("all judgements are hit", () => judgementResults.All(j => j.Type.IsHit())); + } + private void assertAllMaxJudgements() { AddAssert("All judgements max", () => @@ -522,6 +563,11 @@ namespace osu.Game.Rulesets.Osu.Tests }, slider_path_length), }; + performTest(frames, [slider], bpm, tickRate); + } + + private void performTest(List frames, List objects, double? bpm = null, int? tickRate = null) + { AddStep("load player", () => { var cpi = new ControlPointInfo(); @@ -531,7 +577,7 @@ namespace osu.Game.Rulesets.Osu.Tests Beatmap.Value = CreateWorkingBeatmap(new Beatmap { - HitObjects = { slider }, + HitObjects = objects, BeatmapInfo = { Difficulty = new BeatmapDifficulty diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index d089e924ca..3276516d0a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -52,8 +52,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 100, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 100, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 99, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 99, slider_end_position, OsuAction.LeftButton), }); assertHeadJudgement(HitResult.Ok); @@ -70,8 +70,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 100, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 100, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 99, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 99, slider_end_position, OsuAction.LeftButton), }, s => { s.SliderVelocityMultiplier = 2; @@ -91,8 +91,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_end_position, OsuAction.LeftButton), }, s => { s.TickDistanceMultiplier = 0.2f; @@ -116,8 +116,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_end_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_end_position, OsuAction.LeftButton), }, s => { s.SliderVelocityMultiplier = 2; @@ -165,8 +165,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.LINEAR, new[] @@ -195,8 +195,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.LINEAR, new[] @@ -224,8 +224,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] @@ -259,8 +259,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] @@ -289,8 +289,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position, OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] @@ -320,8 +320,8 @@ namespace osu.Game.Rulesets.Osu.Tests { performTest(new List { - new OsuReplayFrame(time_slider_start + 150, slider_start_position - new Vector2(20), OsuAction.LeftButton), - new OsuReplayFrame(time_slider_end + 150, slider_start_position - new Vector2(20), OsuAction.LeftButton), + new OsuReplayFrame(time_slider_start + 149, slider_start_position - new Vector2(20), OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 149, slider_start_position - new Vector2(20), OsuAction.LeftButton), }, s => { s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs index d5d3cbb146..0e7d94cb9f 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -10,6 +11,7 @@ using osu.Framework.Input.States; using osu.Framework.Logging; using osu.Framework.Testing.Input; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Tests @@ -58,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Tests foreach (var smokeContainer in smokeContainers) { - if (smokeContainer.Children.Count != 0) + if (smokeContainer.Children.OfType().Any()) return false; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 77b16dd0c5..2e082c292b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -86,9 +86,12 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestSpinningSamplePitchShift() { + PausableSkinnableSound spinSample = null; + AddStep("Add spinner", () => SetContents(_ => testSingle(5, true, 4000))); - AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8); - AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8); + AddUntilStep("wait for spin sample", () => (spinSample = getSpinningSample()) != null); + AddUntilStep("Pitch starts low", () => spinSample.Frequency.Value < 0.8); + AddUntilStep("Pitch increases", () => spinSample.Frequency.Value > 0.8); PausableSkinnableSound getSpinningSample() => drawableSpinner.ChildrenOfType().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin")))); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs index 75bcd809c8..c4e643dcdf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs @@ -22,6 +22,7 @@ using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Storyboards; +using osu.Game.Tests; using osu.Game.Tests.Visual; using osuTK; @@ -107,6 +108,7 @@ namespace osu.Game.Rulesets.Osu.Tests } [Test] + [FlakyTest] public void TestVibrateWithoutSpinningOnCentreWithDoubleTime() { List frames = new List(); @@ -189,6 +191,7 @@ namespace osu.Game.Rulesets.Osu.Tests } [Test] + [FlakyTest] public void TestRewind() { AddStep("set manual clock", () => manualClock = new ManualClock diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 8d81fe3017..367a00ad3b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Storyboards; +using osu.Game.Tests; using osu.Game.Tests.Visual; using osuTK; @@ -152,6 +153,7 @@ namespace osu.Game.Rulesets.Osu.Tests } [Test] + [FlakyTest] public void TestSpinPerMinuteOnRewind() { double estimatedSpm = 0; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 895e9bbdee..c637ed45f7 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -476,15 +476,24 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestHitWindows : HitWindows { - private static readonly DifficultyRange[] ranges = - { - new DifficultyRange(HitResult.Great, 500, 500, 500), - new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window), - }; - public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss; - protected override DifficultyRange[] GetRanges() => ranges; + public override void SetDifficulty(double difficulty) { } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Great: + return 500; + + case HitResult.Miss: + return early_miss_window; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } private partial class ScoreAccessibleReplayPlayer : ReplayPlayer 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 5ea231e606..6510568555 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 @@ -1,10 +1,10 @@  - + - + WinExe diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs index a5282877ee..87e592a41c 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs @@ -1,10 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Beatmaps @@ -16,26 +17,30 @@ namespace osu.Game.Rulesets.Osu.Beatmaps int circles = HitObjects.Count(c => c is HitCircle); int sliders = HitObjects.Count(s => s is Slider); int spinners = HitObjects.Count(s => s is Spinner); + int sum = Math.Max(1, circles + sliders); return new[] { new BeatmapStatistic { - Name = BeatmapsetsStrings.ShowStatsCountCircles, + Name = BeatmapStatisticStrings.Circles, Content = circles.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), + BarDisplayLength = circles / (float)sum, }, new BeatmapStatistic { - Name = BeatmapsetsStrings.ShowStatsCountSliders, + Name = BeatmapStatisticStrings.Sliders, Content = sliders.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), + BarDisplayLength = sliders / (float)sum, }, new BeatmapStatistic { - Name = @"Spinner Count", + Name = BeatmapStatisticStrings.Spinners, Content = spinners.ToString(), CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), + BarDisplayLength = Math.Min(spinners / 10f, 1), } }; } diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index 3c051a6bb1..aa8326c60c 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps ComboOffset = comboData?.ComboOffset ?? 0, // prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance. // this results in more (or less) ticks being generated in + /// The maximum distance between the end of one object and the start of another + /// which allows the objects to be stacked on top of another. + /// + public const int STACK_DISTANCE = 3; public OsuBeatmapProcessor(IBeatmap beatmap) : base(beatmap) @@ -55,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps foreach (var h in hitObjects) h.StackHeight = 0; - if (beatmap.BeatmapInfo.BeatmapVersion >= 6) + if (beatmap.BeatmapVersion >= 6) applyStacking(beatmap, hitObjects, 0, hitObjects.Count - 1); else applyStackingOld(beatmap, hitObjects); @@ -93,8 +97,8 @@ namespace osu.Game.Rulesets.Osu.Beatmaps // We are no longer within stacking range of the next object. break; - if (Vector2Extensions.Distance(stackBaseObject.Position, objectN.Position) < stack_distance - || (stackBaseObject is Slider && Vector2Extensions.Distance(stackBaseObject.EndPosition, objectN.Position) < stack_distance)) + if (Vector2Extensions.Distance(stackBaseObject.Position, objectN.Position) < STACK_DISTANCE + || (stackBaseObject is Slider && Vector2Extensions.Distance(stackBaseObject.EndPosition, objectN.Position) < STACK_DISTANCE)) { stackBaseIndex = n; @@ -163,7 +167,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps * o <- hitCircle has stack of -1 * o <- hitCircle has stack of -2 */ - if (objectN is Slider && Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < stack_distance) + if (objectN is Slider && Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < STACK_DISTANCE) { int offset = objectI.StackHeight - objectN.StackHeight + 1; @@ -171,7 +175,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps { // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above). OsuHitObject objectJ = hitObjects[j]; - if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < stack_distance) + if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < STACK_DISTANCE) objectJ.StackHeight -= offset; } @@ -180,7 +184,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps break; } - if (Vector2Extensions.Distance(objectN.Position, objectI.Position) < stack_distance) + if (Vector2Extensions.Distance(objectN.Position, objectI.Position) < STACK_DISTANCE) { // Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out. //NOTE: Sliders with start positions stacking are a special case that is also handled here. @@ -204,7 +208,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps // We are no longer within stacking range of the previous object. break; - if (Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < stack_distance) + if (Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < STACK_DISTANCE) { objectN.StackHeight = objectI.StackHeight + 1; objectI = objectN; @@ -245,12 +249,12 @@ namespace osu.Game.Rulesets.Osu.Beatmaps // Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where // if we use `EndTime` here it would result in unexpected stacking. - if (Vector2Extensions.Distance(hitObjects[j].Position, currHitObject.Position) < stack_distance) + if (Vector2Extensions.Distance(hitObjects[j].Position, currHitObject.Position) < STACK_DISTANCE) { currHitObject.StackHeight++; startTime = hitObjects[j].StartTime; } - else if (Vector2Extensions.Distance(hitObjects[j].Position, position2) < stack_distance) + else if (Vector2Extensions.Distance(hitObjects[j].Position, position2) < STACK_DISTANCE) { // Case for sliders - bump notes down and right, rather than up and left. sliderStack++; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 9816f6d0a4..dcf8ac0fed 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -12,9 +12,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators public static class AimEvaluator { private const double wide_angle_multiplier = 1.5; - private const double acute_angle_multiplier = 1.95; + private const double acute_angle_multiplier = 2.55; private const double slider_multiplier = 1.35; private const double velocity_change_multiplier = 0.75; + private const double wiggle_multiplier = 1.02; /// /// Evaluates the difficulty of aiming the current object, based on: @@ -33,12 +34,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var osuCurrObj = (OsuDifficultyHitObject)current; var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. - double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; + double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime; // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) @@ -50,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators } // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; + double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime; if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) { @@ -64,56 +66,81 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double acuteAngleBonus = 0; double sliderBonus = 0; double velocityChangeBonus = 0; + double wiggleBonus = 0; double aimStrain = currVelocity; // Start strain with regular velocity. - if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) { - if (osuCurrObj.Angle != null && osuLastObj.Angle != null && osuLastLastObj.Angle != null) + double currAngle = osuCurrObj.Angle.Value; + double lastAngle = osuLastObj.Angle.Value; + + // Rewarding angles, take the smaller velocity as base. + double angleBonus = Math.Min(currVelocity, prevVelocity); + + if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. { - double currAngle = osuCurrObj.Angle.Value; - double lastAngle = osuLastObj.Angle.Value; - double lastLastAngle = osuLastLastObj.Angle.Value; - - // Rewarding angles, take the smaller velocity as base. - double angleBonus = Math.Min(currVelocity, prevVelocity); - - wideAngleBonus = calcWideAngleBonus(currAngle); acuteAngleBonus = calcAcuteAngleBonus(currAngle); - if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2. - acuteAngleBonus = 0; - else - { - acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. - * Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime - * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 - * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter. - } + // Penalize angle repetition. + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3))); - // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. - wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); - // Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse. - acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 3))); + // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter + acuteAngleBonus *= angleBonus * + DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * + DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); + } + + wideAngleBonus = calcWideAngleBonus(currAngle); + + // Penalize angle repetition. + wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); + + // Apply full wide angle bonus for distance more than one diameter + wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter); + + // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle + // https://www.desmos.com/calculator/dp0v0nvowc + wiggleBonus = angleBonus + * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) + * DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + + if (osuLast2Obj != null) + { + // If objects just go back and forth through a middle point - don't give as much wide bonus + // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object + var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; + var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; + + float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; + + if (distance < 1) + { + wideAngleBonus *= 1 - 0.35 * (1 - distance); + } } } if (Math.Max(prevVelocity, currVelocity) != 0) { // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.StrainTime; - currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; + prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime; + currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime; // Scale with ratio of difference compared to 0.5 * max dist. - double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); + double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); + double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); velocityChangeBonus = overlapVelocityBuff * distRatio; // Penalize for rhythm changes. - velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); + velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); } if (osuLastObj.BaseObject is Slider) @@ -122,8 +149,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; } - // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. - aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier); + aimStrain += wiggleBonus * wiggle_multiplier; + aimStrain += velocityChangeBonus * velocity_change_multiplier; + + // Add in acute angle bonus or wide angle bonus, whichever is larger. + aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); + + // Apply high circle size bonus + aimStrain *= osuCurrObj.SmallCircleBonus; // Add in additional slider velocity bonus. if (withSliderTravelDistance) @@ -132,8 +165,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators return aimStrain; } - private static double calcWideAngleBonus(double angle) => Math.Pow(Math.Sin(3.0 / 4 * (Math.Min(5.0 / 6 * Math.PI, Math.Max(Math.PI / 6, angle)) - Math.PI / 6)), 2); + private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); - private static double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle); + private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index 5cb5a8f934..55192df7af 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -52,12 +52,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var currentObj = (OsuDifficultyHitObject)current.Previous(i); var currentHitObject = (OsuHitObject)(currentObj.BaseObject); + cumulativeStrainTime += lastObj.AdjustedDeltaTime; + if (!(currentObj.BaseObject is Spinner)) { double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.StackedEndPosition).Length; - cumulativeStrainTime += lastObj.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); @@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (osuCurrent.BaseObject is Slider osuSlider) { // Invert the scaling factor to determine the true travel distance independent of circle size. - double pixelTravelDistance = osuSlider.LazyTravelDistance / scalingFactor; + double pixelTravelDistance = osuCurrent.LazyTravelDistance / scalingFactor; // Reward sliders based on velocity. sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index d503dd2bcc..9349083951 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { private const int history_time_max = 5 * 1000; // 5 seconds private const int history_objects_max = 32; - private const double rhythm_overall_multiplier = 0.95; - private const double rhythm_ratio_multiplier = 12.0; + private const double rhythm_overall_multiplier = 1.0; + private const double rhythm_ratio_multiplier = 15.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . @@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (current.BaseObject is Spinner) return 0; + var currentOsuObject = (OsuDifficultyHitObject)current; + double rhythmComplexitySum = 0; double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; @@ -62,22 +64,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. - double currDelta = currObj.StrainTime; - double prevDelta = prevObj.StrainTime; - double lastDelta = lastObj.StrainTime; + // Use custom cap value to ensure that that at this point delta time is actually zero + double currDelta = Math.Max(currObj.DeltaTime, 1e-7); + double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7); + double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7); // calculate how much current delta difference deserves a rhythm bonus // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) - double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta); - double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2)); + double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); + + // Take only the fractional part of the value since we're only interested in punishing multiples + double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); + + double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); // reduce ratio bonus if delta difference is too big - double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta); - double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0); + double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0); double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); - double effectiveRatio = windowPenalty * currRatio * fractionMultiplier; + double effectiveRatio = windowPenalty * currRatio * differenceMultiplier; if (firstDeltaSwitch) { @@ -170,7 +176,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators prevObj = currObj; } - return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) + double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) + rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0)); + + return rhythmDifficulty; } private class Island : IEquatable diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index a5f6468f17..a58c1d3685 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -2,9 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators @@ -14,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double speed_balancing_factor = 40; - private const double distance_multiplier = 0.94; + private const double distance_multiplier = 0.8; /// /// Evaluates the difficulty of tapping the current object, based on: @@ -24,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators /// and how easily they can be cheesed. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current) + public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods) { if (current.BaseObject is Spinner) return 0; @@ -33,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var osuCurrObj = (OsuDifficultyHitObject)current; var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; - double strainTime = osuCurrObj.StrainTime; + double strainTime = osuCurrObj.AdjustedDeltaTime; double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); // Cap deltatime to the OD 300 hitwindow. @@ -56,6 +60,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier; + // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps + distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); + + if (mods.OfType().Any()) + distanceBonus = 0; + // Base difficulty with all bonuses double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index a3c0209a08..9cab454142 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty { @@ -19,6 +20,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("aim_difficulty")] public double AimDifficulty { get; set; } + /// + /// The number of s weighted by difficulty. + /// + [JsonProperty("aim_difficult_slider_count")] + public double AimDifficultSliderCount { get; set; } + /// /// The difficulty corresponding to the speed skill. /// @@ -46,29 +53,36 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("slider_factor")] public double SliderFactor { get; set; } + /// + /// Describes how much of is contributed to by hitcircles or sliders + /// A value closer to 0.0 indicates most of is contributed by hitcircles + /// A value closer to Infinity indicates most of is contributed by sliders + /// + [JsonProperty("aim_top_weighted_slider_factor")] + public double AimTopWeightedSliderFactor { get; set; } + + /// + /// Describes how much of is contributed to by hitcircles or sliders + /// A value closer to 0.0 indicates most of is contributed by hitcircles + /// A value closer to Infinity indicates most of is contributed by sliders + /// + [JsonProperty("speed_top_weighted_slider_factor")] + public double SpeedTopWeightedSliderFactor { get; set; } + [JsonProperty("aim_difficult_strain_count")] public double AimDifficultStrainCount { get; set; } [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } - /// - /// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc). - /// - /// - /// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing. - /// - [JsonProperty("approach_rate")] - public double ApproachRate { get; set; } + [JsonProperty("nested_score_per_object")] + public double NestedScorePerObject { get; set; } - /// - /// The perceived overall difficulty inclusive of rate-adjusting mods (DT/HT/etc). - /// - /// - /// Rate-adjusting mods don't directly affect the overall difficulty value, but have a perceived effect as a result of adjusting audio timing. - /// - [JsonProperty("overall_difficulty")] - public double OverallDifficulty { get; set; } + [JsonProperty("legacy_score_base_multiplier")] + public double LegacyScoreBaseMultiplier { get; set; } + + [JsonProperty("maximum_legacy_combo_score")] + public double MaximumLegacyComboScore { get; set; } /// /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. @@ -97,8 +111,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_AIM, AimDifficulty); yield return (ATTRIB_ID_SPEED, SpeedDifficulty); - yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty); - yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate); yield return (ATTRIB_ID_DIFFICULTY, StarRating); if (ShouldSerializeFlashlightDifficulty()) @@ -109,6 +121,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount); yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); + yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); + yield return (ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR, AimTopWeightedSliderFactor); + yield return (ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR, SpeedTopWeightedSliderFactor); + yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject); + yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier); + yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -117,14 +135,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty AimDifficulty = values[ATTRIB_ID_AIM]; SpeedDifficulty = values[ATTRIB_ID_SPEED]; - OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY]; - ApproachRate = values[ATTRIB_ID_APPROACH_RATE]; StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT]; SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; + AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; + AimTopWeightedSliderFactor = values[ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR]; + SpeedTopWeightedSliderFactor = values[ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR]; + NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT]; + LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER]; + MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE]; DrainRate = onlineInfo.DrainRate; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 575e03051c..504fddbb71 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -13,6 +11,7 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; +using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Scoring; @@ -22,54 +21,93 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - private const double difficulty_multiplier = 0.0675; + private const double star_rating_multiplier = 0.0265; - public override int Version => 20241007; + public override int Version => 20251020; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { } + public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate) + { + double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate; + return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); + } + + public static double CalculateRateAdjustedOverallDifficulty(double overallDifficulty, double clockRate) + { + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(overallDifficulty); + + double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; + + return (79.5 - hitWindowGreat) / 6; + } + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; - double aimRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier; - double aimRatingNoSliders = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier; - double speedRating = Math.Sqrt(skills[2].DifficultyValue()) * difficulty_multiplier; - double speedNotes = ((Speed)skills[2]).RelevantNoteCount(); + var aim = skills.OfType().Single(a => a.IncludeSliders); + var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); + var speed = skills.OfType().Single(); + var flashlight = skills.OfType().SingleOrDefault(); + + double speedNotes = speed.RelevantNoteCount(); + + double aimDifficultStrainCount = aim.CountTopWeightedStrains(); + double speedDifficultStrainCount = speed.CountTopWeightedStrains(); + + double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(); + double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(); + + double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount); + + double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(); + double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount); + + double difficultSliders = aim.GetDifficultSliders(); + + double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate); + double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); + + int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); + int sliderCount = beatmap.HitObjects.Count(h => h is Slider); + int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); + + int totalHits = beatmap.HitObjects.Count; + + double drainRate = beatmap.Difficulty.DrainRate; + + double aimDifficultyValue = aim.DifficultyValue(); + double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); + double speedDifficultyValue = speed.DifficultyValue(); + + double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); + double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1; + + var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor); + + double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); + double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); double flashlightRating = 0.0; - if (mods.Any(h => h is OsuModFlashlight)) - flashlightRating = Math.Sqrt(skills[3].DifficultyValue()) * difficulty_multiplier; + if (flashlight is not null) + flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); - double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; + double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); + double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); - double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountTopWeightedStrains(); - double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountTopWeightedStrains(); - - if (mods.Any(m => m is OsuModTouchDevice)) - { - aimRating = Math.Pow(aimRating, 0.8); - flashlightRating = Math.Pow(flashlightRating, 0.8); - } - - if (mods.Any(h => h is OsuModRelax)) - { - aimRating *= 0.9; - speedRating = 0.0; - flashlightRating *= 0.7; - } + var simulator = new OsuLegacyScoreSimulator(); + var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); - double baseFlashlightPerformance = 0.0; - - if (mods.Any(h => h is OsuModFlashlight)) - baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); double basePerformance = Math.Pow( @@ -78,45 +116,53 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); - double starRating = basePerformance > 0.00001 - ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 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 hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); - int sliderCount = beatmap.HitObjects.Count(h => h is Slider); - int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); - - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - - double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; + double starRating = calculateStarRating(basePerformance); OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, Mods = mods, AimDifficulty = aimRating, + AimDifficultSliderCount = difficultSliders, SpeedDifficulty = speedRating, SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, - AimDifficultStrainCount = aimDifficultyStrainCount, - SpeedDifficultStrainCount = speedDifficultyStrainCount, - ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, - OverallDifficulty = (80 - hitWindowGreat) / 6, + AimDifficultStrainCount = aimDifficultStrainCount, + SpeedDifficultStrainCount = speedDifficultStrainCount, + AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, + SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), - HitCircleCount = hitCirclesCount, + HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, + NestedScorePerObject = sliderNestedScorePerObject, + LegacyScoreBaseMultiplier = legacyScoreBaseMultiplier, + MaximumLegacyComboScore = scoreAttributes.ComboScore }; return attributes; } + private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) + { + double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)); + double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue)); + + double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1); + + return calculateStarRating(totalValue); + } + + private double calculateStarRating(double basePerformance) + { + if (basePerformance <= 0.00001) + return 0; + + return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); + } + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { List objects = new List(); @@ -125,8 +171,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty // If the map has less than two OsuHitObjects, the enumerator will not return anything. for (int i = 1; i < beatmap.HitObjects.Count; i++) { - var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; - objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], lastLast, clockRate, objects, objects.Count)); + objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate, objects, objects.Count)); } return objects; @@ -155,7 +200,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty new OsuModEasy(), new OsuModHardRock(), new OsuModFlashlight(), - new MultiMod(new OsuModFlashlight(), new OsuModHidden()) + new OsuModHidden(), }; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs new file mode 100644 index 0000000000..0d406ea72a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -0,0 +1,200 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Osu.Difficulty +{ + public class OsuLegacyScoreMissCalculator + { + private readonly ScoreInfo score; + private readonly OsuDifficultyAttributes attributes; + + public OsuLegacyScoreMissCalculator(ScoreInfo scoreInfo, OsuDifficultyAttributes attributes) + { + score = scoreInfo; + this.attributes = attributes; + } + + public double Calculate() + { + if (attributes.MaxCombo == 0 || score.LegacyTotalScore == null) + return 0; + + double scoreV1Multiplier = attributes.LegacyScoreBaseMultiplier * getLegacyScoreMultiplier(); + double relevantComboPerObject = calculateRelevantScoreComboPerObject(); + + double maximumMissCount = calculateMaximumComboBasedMissCount(); + + double scoreObtainedDuringMaxCombo = calculateScoreAtCombo(score.MaxCombo, relevantComboPerObject, scoreV1Multiplier); + double remainingScore = score.LegacyTotalScore.Value - scoreObtainedDuringMaxCombo; + + if (remainingScore <= 0) + return maximumMissCount; + + double remainingCombo = attributes.MaxCombo - score.MaxCombo; + double expectedRemainingScore = calculateScoreAtCombo(remainingCombo, relevantComboPerObject, scoreV1Multiplier); + + double scoreBasedMissCount = expectedRemainingScore / remainingScore; + + // If there's less then one miss detected - let combo-based miss count decide if this is FC or not + scoreBasedMissCount = Math.Max(scoreBasedMissCount, 1); + + // Cap result by very harsh version of combo-based miss count + return Math.Min(scoreBasedMissCount, maximumMissCount); + } + + /// + /// Calculates the amount of score that would be achieved at a given combo. + /// + private double calculateScoreAtCombo(double combo, double relevantComboPerObject, double scoreV1Multiplier) + { + int countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); + int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + + int totalHits = countGreat + countOk + countMeh + countMiss; + + double estimatedObjects = combo / relevantComboPerObject - 1; + + // The combo portion of ScoreV1 follows arithmetic progression + // Therefore, we calculate the combo portion of score using the combo per object and our current combo. + double comboScore = relevantComboPerObject > 0 ? (2 * (relevantComboPerObject - 1) + (estimatedObjects - 1) * relevantComboPerObject) * estimatedObjects / 2 : 0; + + // We then apply the accuracy and ScoreV1 multipliers to the resulting score. + comboScore *= score.Accuracy * 300 / 25 * scoreV1Multiplier; + + double objectsHit = (totalHits - countMiss) * combo / attributes.MaxCombo; + + // Score also has a non-combo portion we need to create the final score value. + double nonComboScore = (300 + attributes.NestedScorePerObject) * score.Accuracy * objectsHit; + + return comboScore + nonComboScore; + } + + /// + /// Calculates the relevant combo per object for legacy score. + /// This assumes a uniform distribution for circles and sliders. + /// This handles cases where objects (such as buzz sliders) do not fit a normal arithmetic progression model. + /// + private double calculateRelevantScoreComboPerObject() + { + double comboScore = attributes.MaximumLegacyComboScore; + + // We then reverse apply the ScoreV1 multipliers to get the raw value. + comboScore /= 300.0 / 25.0 * attributes.LegacyScoreBaseMultiplier; + + // Reverse the arithmetic progression to work out the amount of combo per object based on the score. + double result = (attributes.MaxCombo - 2) * attributes.MaxCombo; + result /= Math.Max(attributes.MaxCombo + 2 * (comboScore - 1), 1); + + return result; + } + + /// + /// This function is a harsher version of current combo-based miss count, used to provide reasonable value for cases where score-based miss count can't do this. + /// + private double calculateMaximumComboBasedMissCount() + { + int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + + if (attributes.SliderCount <= 0) + return countMiss; + + int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + + int totalImperfectHits = countOk + countMeh + countMiss; + + double missCount = 0; + + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it + // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map + double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + + if (score.MaxCombo < fullComboThreshold) + missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); + + // In classic scores there can't be more misses than a sum of all non-perfect judgements + missCount = Math.Min(missCount, totalImperfectHits); + + // Every slider has *at least* 2 combo attributed in classic mechanics. + // If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end) + // Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break. + // It must have been a slider end. + int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - score.MaxCombo) / 2); + + int scoreMissCount = score.Statistics.GetValueOrDefault(HitResult.Miss); + + double sliderBreaks = missCount - scoreMissCount; + + if (sliderBreaks > maxPossibleSliderBreaks) + missCount = scoreMissCount + maxPossibleSliderBreaks; + + return missCount; + } + + /// + /// Logic copied from . + /// + private double getLegacyScoreMultiplier() + { + bool scoreV2 = score.Mods.Any(m => m is ModScoreV2); + + double multiplier = 1.0; + + foreach (var mod in score.Mods) + { + switch (mod) + { + case OsuModNoFail: + multiplier *= scoreV2 ? 1.0 : 0.5; + break; + + case OsuModEasy: + multiplier *= 0.5; + break; + + case OsuModHalfTime: + case OsuModDaycore: + multiplier *= 0.3; + break; + + case OsuModHidden: + multiplier *= 1.06; + break; + + case OsuModHardRock: + multiplier *= scoreV2 ? 1.10 : 1.06; + break; + + case OsuModDoubleTime: + case OsuModNightcore: + multiplier *= scoreV2 ? 1.20 : 1.12; + break; + + case OsuModFlashlight: + multiplier *= 1.12; + break; + + case OsuModSpunOut: + multiplier *= 0.9; + break; + + case OsuModRelax: + case OsuModAutopilot: + return 0; + } + } + + return multiplier; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index 0aeaf7669f..8577eff11f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -24,6 +24,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } + [JsonProperty("speed_deviation")] + public double? SpeedDeviation { get; set; } + + [JsonProperty("combo_based_estimated_miss_count")] + public double ComboBasedEstimatedMissCount { get; set; } + + [JsonProperty("score_based_estimated_miss_count")] + public double? ScoreBasedEstimatedMissCount { get; set; } + + [JsonProperty("aim_estimated_slider_breaks")] + public double AimEstimatedSliderBreaks { get; set; } + + [JsonProperty("speed_estimated_slider_breaks")] + public double SpeedEstimatedSliderBreaks { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 31b00dba2b..741ddb3d4f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -4,19 +4,25 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. private bool usingClassicSliderAccuracy; + private bool usingScoreV2; private double accuracy; private int scoreMaxCombo; @@ -40,6 +46,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty /// private double effectiveMissCount; + private double clockRate; + private double greatHitWindow; + private double okHitWindow; + private double mehHitWindow; + private double overallDifficulty; + private double approachRate; + + private double? speedDeviation; + + private double aimEstimatedSliderBreaks; + private double speedEstimatedSliderBreaks; + public OsuPerformanceCalculator() : base(new OsuRuleset()) { @@ -50,6 +68,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty var osuAttributes = (OsuDifficultyAttributes)attributes; usingClassicSliderAccuracy = score.Mods.OfType().Any(m => m.NoSliderHeadAccuracy.Value); + usingScoreV2 = score.Mods.Any(m => m is ModScoreV2); accuracy = score.Accuracy; scoreMaxCombo = score.MaxCombo; @@ -61,30 +80,36 @@ namespace osu.Game.Rulesets.Osu.Difficulty countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss); effectiveMissCount = countMiss; - if (osuAttributes.SliderCount > 0) + var difficulty = score.BeatmapInfo!.Difficulty.Clone(); + + score.Mods.OfType().ForEach(m => m.ApplyToDifficulty(difficulty)); + + clockRate = ModUtils.CalculateRateWithMods(score.Mods); + + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(difficulty.OverallDifficulty); + + greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate; + okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate; + mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate; + + approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); + overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate); + + double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); + double? scoreBasedEstimatedMissCount = null; + + if (usingClassicSliderAccuracy && score.LegacyTotalScore != null) { - if (usingClassicSliderAccuracy) - { - // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount; + var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes); + scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate(); - if (scoreMaxCombo < fullComboThreshold) - effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - - // In classic scores there can't be more misses than a sum of all non-perfect judgements - effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits); - } - else - { - double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped; - - if (scoreMaxCombo < fullComboThreshold) - effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - - // Combine regular misses with tick misses since tick misses break combo as well - effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss); - } + effectiveMissCount = scoreBasedEstimatedMissCount.Value; + } + else + { + // Use combo-based miss count if this isn't a legacy score + effectiveMissCount = comboBasedEstimatedMissCount; } effectiveMissCount = Math.Max(countMiss, effectiveMissCount); @@ -100,20 +125,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (score.Mods.Any(h => h is OsuModRelax)) { - // https://www.desmos.com/calculator/bc9eybdthb + // https://www.desmos.com/calculator/vspzsop6td // we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 // this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) - double okMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 1.8) : 1.0); - double mehMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 5) : 1.0); + double okMultiplier = 0.75 * Math.Max(0.0, overallDifficulty > 0.0 ? 1 - overallDifficulty / 13.33 : 1.0); + double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0); // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits); } + speedDeviation = calculateSpeedDeviation(osuAttributes); + double aimValue = computeAimValue(score, osuAttributes); double speedValue = computeSpeedValue(score, osuAttributes); double accuracyValue = computeAccuracyValue(score, osuAttributes); double flashlightValue = computeFlashlightValue(score, osuAttributes); + double totalValue = Math.Pow( Math.Pow(aimValue, 1.1) + @@ -129,44 +157,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty Accuracy = accuracyValue, Flashlight = flashlightValue, EffectiveMissCount = effectiveMissCount, + ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, + ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, + AimEstimatedSliderBreaks = aimEstimatedSliderBreaks, + SpeedEstimatedSliderBreaks = speedEstimatedSliderBreaks, + SpeedDeviation = speedDeviation, Total = totalValue }; } private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - double aimValue = OsuStrainSkill.DifficultyToPerformance(attributes.AimDifficulty); + if (score.Mods.Any(h => h is OsuModAutopilot)) + return 0.0; - double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - aimValue *= lengthBonus; + double aimDifficulty = attributes.AimDifficulty; - if (effectiveMissCount > 0) - aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); - - double approachRateFactor = 0.0; - if (attributes.ApproachRate > 10.33) - approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33); - else if (attributes.ApproachRate < 8.0) - approachRateFactor = 0.05 * (8.0 - attributes.ApproachRate); - - if (score.Mods.Any(h => h is OsuModRelax)) - approachRateFactor = 0.0; - - aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. - - if (score.Mods.Any(m => m is OsuModBlinds)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); - else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) - { - // 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); - } - - // We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator. - double estimateDifficultSliders = attributes.SliderCount * 0.15; - - if (attributes.SliderCount > 0) + if (attributes.SliderCount > 0 && attributes.AimDifficultSliderCount > 0) { double estimateImproperlyFollowedDifficultSliders; @@ -174,29 +181,50 @@ namespace osu.Game.Rulesets.Osu.Difficulty { // When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders int maximumPossibleDroppedSliders = totalImperfectHits; - estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); + estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, attributes.AimDifficultSliderCount); } else { // We add tick misses here since they too mean that the player didn't follow the slider properly // We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly - estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, estimateDifficultSliders); + estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, attributes.AimDifficultSliderCount); } - double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor; - aimValue *= sliderNerfFactor; + double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / attributes.AimDifficultSliderCount, 3) + attributes.SliderFactor; + aimDifficulty *= sliderNerfFactor; + } + + double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty); + + double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); + aimValue *= lengthBonus; + + if (effectiveMissCount > 0) + { + aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes); + + double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); + + aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount); + } + + // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. + if (score.Mods.Any(m => m is OsuModBlinds)) + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); + else if (score.Mods.Any(m => m is OsuModTraceable)) + { + aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor); } aimValue *= accuracy; - // It is important to consider accuracy difficulty when scaling with accuracy. - aimValue *= 0.98 + Math.Pow(attributes.OverallDifficulty, 2) / 2500; return aimValue; } private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - if (score.Mods.Any(h => h is OsuModRelax)) + if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null) return 0.0; double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); @@ -206,37 +234,37 @@ namespace osu.Game.Rulesets.Osu.Difficulty speedValue *= lengthBonus; if (effectiveMissCount > 0) - speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); + { + speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes); - double approachRateFactor = 0.0; - if (attributes.ApproachRate > 10.33) - approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33); + double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); - speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. + speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount); + } + // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if (score.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 (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { - // 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); + speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate); } + double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); + speedValue *= speedHighDeviationMultiplier; + // Calculate accuracy assuming the worst case scenario - double relevantTotalDiff = totalHits - attributes.SpeedNoteCount; + double relevantTotalDiff = Math.Max(0, totalHits - attributes.SpeedNoteCount); double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff); double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat)); double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk)); double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); // Scale the speed value with accuracy and OD. - speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2); - - // Scale the speed value with # of 50s to punish doubletapping. - speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); + speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); return speedValue; } @@ -249,11 +277,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. double betterAccuracyPercentage; int amountHitObjectsWithAccuracy = attributes.HitCircleCount; - if (!usingClassicSliderAccuracy) + if (!usingClassicSliderAccuracy || usingScoreV2) amountHitObjectsWithAccuracy += attributes.SliderCount; if (amountHitObjectsWithAccuracy > 0) - betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); + betterAccuracyPercentage = ((countGreat - Math.Max(totalHits - amountHitObjectsWithAccuracy, 0)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); else betterAccuracyPercentage = 0; @@ -263,7 +291,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. - double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; + double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); @@ -272,7 +300,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (score.Mods.Any(m => m is OsuModBlinds)) accuracyValue *= 1.14; else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) - accuracyValue *= 1.08; + { + // Decrease bonus for AR > 10 + accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); + } if (score.Mods.Any(m => m is OsuModFlashlight)) accuracyValue *= 1.02; @@ -293,24 +324,179 @@ namespace osu.Game.Rulesets.Osu.Difficulty flashlightValue *= getComboScalingFactor(attributes); - // 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 double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes) + { + if (attributes.SliderCount <= 0) + return countMiss; + + double missCount = countMiss; + + if (usingClassicSliderAccuracy) + { + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it + // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map + double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + + if (scoreMaxCombo < fullComboThreshold) + missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // In classic scores there can't be more misses than a sum of all non-perfect judgements + missCount = Math.Min(missCount, totalImperfectHits); + + // Every slider has *at least* 2 combo attributed in classic mechanics. + // If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end) + // Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break. + // It must have been a slider end. + int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - scoreMaxCombo) / 2); + + double sliderBreaks = missCount - countMiss; + + if (sliderBreaks > maxPossibleSliderBreaks) + missCount = countMiss + maxPossibleSliderBreaks; + } + else + { + double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped; + + if (scoreMaxCombo < fullComboThreshold) + missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // Combine regular misses with tick misses since tick misses break combo as well + missCount = Math.Min(missCount, countSliderTickMiss + countMiss); + } + + return missCount; + } + + private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) + { + if (!usingClassicSliderAccuracy || countOk == 0) + return 0; + + double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo; + double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); + + // Scores with more Oks are more likely to have slider breaks. + double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk; + + // There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. + estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2); + + return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); + } + + /// + /// Estimates player's deviation on speed notes using , assuming worst-case. + /// Treats all speed notes as hit circles. + /// + private double? calculateSpeedDeviation(OsuDifficultyAttributes attributes) + { + if (totalSuccessfulHits == 0) + return null; + + // Calculate accuracy assuming the worst case scenario + double speedNoteCount = attributes.SpeedNoteCount; + speedNoteCount += (totalHits - attributes.SpeedNoteCount) * 0.1; + + // Assume worst case: all mistakes were on speed notes + double relevantCountMiss = Math.Min(countMiss, speedNoteCount); + double relevantCountMeh = Math.Min(countMeh, speedNoteCount - relevantCountMiss); + double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh); + double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk); + + return calculateDeviation(relevantCountGreat, relevantCountOk, relevantCountMeh); + } + + /// + /// Estimates the player's tap deviation based on the OD, given number of greats, oks, mehs and misses, + /// assuming the player's mean hit error is 0. The estimation is consistent in that two SS scores on the same map with the same settings + /// will always return the same deviation. Misses are ignored because they are usually due to misaiming. + /// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution. + /// + private double? calculateDeviation(double relevantCountGreat, double relevantCountOk, double relevantCountMeh) + { + if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0) + return null; + + // The sample proportion of successful hits. + double n = Math.Max(1, relevantCountGreat + relevantCountOk); + double p = relevantCountGreat / n; + + // 99% critical value for the normal distribution (one-tailed). + const double z = 2.32634787404; + + // We can be 99% confident that the population proportion is at least this value. + double pLowerBound = Math.Min(p, (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4)); + + double deviation; + + // Tested max precision for the deviation calculation. + if (pLowerBound > 0.01) + { + // Compute deviation assuming greats and oks are normally distributed. + deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); + + // Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above. + // This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow. + double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2)) + / (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation))); + + deviation *= Math.Sqrt(1 - okHitWindowTailAmount); + } + else + { + // A tested limit value for the case of a score only containing oks. + deviation = okHitWindow / Math.Sqrt(3); + } + + // Compute and add the variance for mehs, assuming that they are uniformly distributed. + double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3; + + deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); + + return deviation; + } + + // Calculates multiplier for speed to account for improper tapping based on the deviation and speed difficulty + // https://www.desmos.com/calculator/dmogdhzofn + private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attributes) + { + if (speedDeviation == null) + return 0; + + double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); + + // Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty. + // This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value. + double excessSpeedDifficultyCutoff = 100 + 220 * Math.Pow(22 / speedDeviation.Value, 6.5); + + if (speedValue <= excessSpeedDifficultyCutoff) + return 1.0; + + const double scale = 50; + double adjustedSpeedValue = scale * (Math.Log((speedValue - excessSpeedDifficultyCutoff) / scale + 1) + excessSpeedDifficultyCutoff / scale); + + // 220 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible + double lerp = 1 - DifficultyCalculationUtils.ReverseLerp(speedDeviation.Value, 22.0, 27.0); + adjustedSpeedValue = double.Lerp(adjustedSpeedValue, speedValue, lerp); + + return adjustedSpeedValue / speedValue; + } + // Miss penalty assumes that a player will miss on the hardest parts of a map, // so we use the amount of relatively difficult sections to adjust miss penalty // to make it more punishing on maps with lower amount of hard sections. private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1); private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); + private int totalHits => countGreat + countOk + countMeh + countMiss; + private int totalSuccessfulHits => countGreat + countOk + countMeh; private int totalImperfectHits => countOk + countMeh + countMiss; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs new file mode 100644 index 0000000000..2a050c0920 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.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.Linq; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Difficulty +{ + public class OsuRatingCalculator + { + private const double difficulty_multiplier = 0.0675; + + private readonly Mod[] mods; + private readonly int totalHits; + private readonly double approachRate; + private readonly double overallDifficulty; + private readonly double mechanicalDifficultyRating; + private readonly double sliderFactor; + + public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor) + { + this.mods = mods; + this.totalHits = totalHits; + this.approachRate = approachRate; + this.overallDifficulty = overallDifficulty; + this.mechanicalDifficultyRating = mechanicalDifficultyRating; + this.sliderFactor = sliderFactor; + } + + public double ComputeAimRating(double aimDifficultyValue) + { + if (mods.Any(m => m is OsuModAutopilot)) + return 0; + + double aimRating = CalculateDifficultyRating(aimDifficultyValue); + + if (mods.Any(m => m is OsuModTouchDevice)) + aimRating = Math.Pow(aimRating, 0.8); + + if (mods.Any(m => m is OsuModRelax)) + aimRating *= 0.9; + + if (mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; + aimRating *= 1.0 - magnetisedStrength; + } + + double ratingMultiplier = 1.0; + + double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); + + double approachRateFactor = 0.0; + if (approachRate > 10.33) + approachRateFactor = 0.3 * (approachRate - 10.33); + else if (approachRate < 8.0) + approachRateFactor = 0.05 * (8.0 - approachRate); + + if (mods.Any(h => h is OsuModRelax)) + approachRateFactor = 0.0; + + ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + double visibilityFactor = calculateAimVisibilityFactor(approachRate); + ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor); + } + + // It is important to consider accuracy difficulty when scaling with accuracy. + ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; + + return aimRating * Math.Cbrt(ratingMultiplier); + } + + public double ComputeSpeedRating(double speedDifficultyValue) + { + if (mods.Any(m => m is OsuModRelax)) + return 0; + + double speedRating = CalculateDifficultyRating(speedDifficultyValue); + + if (mods.Any(m => m is OsuModAutopilot)) + speedRating *= 0.5; + + if (mods.Any(m => m is OsuModMagnetised)) + { + // reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x + float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; + speedRating *= 1.0 - magnetisedStrength * 0.3; + } + + double ratingMultiplier = 1.0; + + double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); + + double approachRateFactor = 0.0; + if (approachRate > 10.33) + approachRateFactor = 0.3 * (approachRate - 10.33); + + if (mods.Any(m => m is OsuModAutopilot)) + approachRateFactor = 0.0; + + ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); + ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor); + } + + ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; + + return speedRating * Math.Cbrt(ratingMultiplier); + } + + public double ComputeFlashlightRating(double flashlightDifficultyValue) + { + if (!mods.Any(m => m is OsuModFlashlight)) + return 0; + + double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue); + + if (mods.Any(m => m is OsuModTouchDevice)) + flashlightRating = Math.Pow(flashlightRating, 0.8); + + if (mods.Any(m => m is OsuModRelax)) + flashlightRating *= 0.7; + else if (mods.Any(m => m is OsuModAutopilot)) + flashlightRating *= 0.4; + + if (mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; + flashlightRating *= 1.0 - magnetisedStrength; + } + + if (mods.Any(m => m is OsuModDeflate)) + { + float deflateInitialScale = mods.OfType().First().StartScale.Value; + flashlightRating *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1); + } + + double ratingMultiplier = 1.0; + + // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. + ratingMultiplier *= 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); + + // It is important to consider accuracy difficulty when scaling with accuracy. + ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; + + return flashlightRating * Math.Sqrt(ratingMultiplier); + } + + private double calculateAimVisibilityFactor(double approachRate) + { + const double ar_factor_end_point = 11.5; + + double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); + double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor); + + return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); + } + + private double calculateSpeedVisibilityFactor(double approachRate) + { + const double ar_factor_end_point = 11.5; + + double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); + double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor); + + return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); + } + + /// + /// Calculates a visibility bonus that is applicable to Hidden and Traceable. + /// + public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1) + { + // NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side. + bool isAlwaysPartiallyVisible = mods.OfType().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType().Any(); + + // Start from normal curve, rewarding lower AR up to AR7 + // TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses + // This means it has an advantage over HD, so we decrease the multiplier to compensate + // This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible) + double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7)); + + readingBonus *= visibilityFactor; + + // We want to reward slideraim on low AR less + double sliderVisibilityFactor = Math.Pow(sliderFactor, 3); + + // For AR up to 0 - reduce reward for very low ARs when object is visible + if (approachRate < 7) + readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor; + + // Starting from AR0 - cap values so they won't grow to infinity + if (approachRate < 0) + readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; + + return readingBonus; + } + + public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 5e4c5c1ee9..5e9fc10ef8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -28,11 +28,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; + protected new OsuHitObject LastObject => (OsuHitObject)base.LastObject; /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. + /// capped to a minimum of ms. /// - public readonly double StrainTime; + public readonly double AdjustedDeltaTime; /// /// Normalised distance from the "lazy" end position of the previous to the start position of this . @@ -75,6 +76,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double TravelTime { get; private set; } + /// + /// The position of the cursor at the point of completion of this if it is a + /// and was hit with as few movements as possible. + /// + public Vector2? LazyEndPosition { get; private set; } + + /// + /// The distance travelled by the cursor upon completion of this if it is a + /// and was hit with as few movements as possible. + /// + public double LazyTravelDistance { get; private set; } + + /// + /// The time taken by the cursor upon completion of this if it is a + /// and was hit with as few movements as possible. + /// + public double LazyTravelTime { get; private set; } + /// /// Angle the player has to take to hit this . /// Calculated as the angle between the circles (current-2, current-1, current). @@ -86,17 +105,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double HitWindowGreat { get; private set; } - private readonly OsuHitObject? lastLastObject; - private readonly OsuHitObject lastObject; + /// + /// Selective bonus for maps with higher circle size. + /// + public double SmallCircleBonus { get; private set; } - public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject? lastLastObject, double clockRate, List objects, int index) + private readonly OsuDifficultyHitObject? lastLastDifficultyObject; + private readonly OsuDifficultyHitObject? lastDifficultyObject; + + public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) : base(hitObject, lastObject, clockRate, objects, index) { - this.lastLastObject = lastLastObject as OsuHitObject; - this.lastObject = (OsuHitObject)lastObject; + lastLastDifficultyObject = index > 1 ? (OsuDifficultyHitObject)objects[index - 2] : null; + lastDifficultyObject = index > 0 ? (OsuDifficultyHitObject)objects[index - 1] : null; // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. - StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); + AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); + + SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); if (BaseObject is Slider sliderObject) { @@ -107,6 +133,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; } + computeSliderCursorPosition(); setDistances(clockRate); } @@ -161,35 +188,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { if (BaseObject is Slider currentSlider) { - computeSliderCursorPosition(currentSlider); // Bonus for repeat sliders until a better per nested object strain system can be achieved. - TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); - TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); + TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); + TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner - if (BaseObject is Spinner || lastObject is Spinner) + if (BaseObject is Spinner || LastObject is Spinner) return; // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; - if (BaseObject.Radius < 30) - { - float smallCircleBonus = Math.Min(30 - (float)BaseObject.Radius, 5) / 50; - scalingFactor *= 1 + smallCircleBonus; - } - - Vector2 lastCursorPosition = getEndCursorPosition(lastObject); + Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; - MinimumJumpTime = StrainTime; + MinimumJumpTime = AdjustedDeltaTime; MinimumJumpDistance = LazyJumpDistance; - if (lastObject is Slider lastSlider) + if (LastObject is Slider lastSlider && lastDifficultyObject != null) { - double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); - MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME); + double lastTravelTime = Math.Max(lastDifficultyObject.LazyTravelTime / clockRate, MIN_DELTA_TIME); + MinimumJumpTime = Math.Max(AdjustedDeltaTime - lastTravelTime, MIN_DELTA_TIME); // // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. @@ -217,11 +237,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); } - if (lastLastObject != null && !(lastLastObject is Spinner)) + if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner) { - Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastObject); + Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); - Vector2 v1 = lastLastCursorPosition - lastObject.StackedPosition; + Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition; Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; float dot = Vector2.Dot(v1, v2); @@ -231,9 +251,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing } } - private void computeSliderCursorPosition(Slider slider) + private void computeSliderCursorPosition() { - if (slider.LazyEndPosition != null) + if (BaseObject is not Slider slider) + return; + + if (LazyEndPosition != null) return; // TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from @@ -280,15 +303,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing nestedObjects = reordered; } - slider.LazyTravelTime = trackingEndTime - slider.StartTime; + LazyTravelTime = trackingEndTime - slider.StartTime; - double endTimeMin = slider.LazyTravelTime / slider.SpanDuration; + double endTimeMin = LazyTravelTime / slider.SpanDuration; if (endTimeMin % 2 >= 1) endTimeMin = 1 - endTimeMin % 1; else endTimeMin %= 1; - slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. + LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. Vector2 currCursorPosition = slider.StackedPosition; @@ -310,7 +333,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement. // For sliders that are circular, the lazy end position may actually be farther away than the sliders true end. // This code is designed to prevent buffing situations where lazy end is actually a less efficient movement. - Vector2 lazyMovement = Vector2.Subtract((Vector2)slider.LazyEndPosition, currCursorPosition); + Vector2 lazyMovement = Vector2.Subtract((Vector2)LazyEndPosition, currCursorPosition); if (lazyMovement.Length < currMovement.Length) currMovement = lazyMovement; @@ -328,25 +351,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance. currCursorPosition = Vector2.Add(currCursorPosition, Vector2.Multiply(currMovement, (float)((currMovementLength - requiredMovement) / currMovementLength))); currMovementLength *= (currMovementLength - requiredMovement) / currMovementLength; - slider.LazyTravelDistance += (float)currMovementLength; + LazyTravelDistance += currMovementLength; } if (i == nestedObjects.Count - 1) - slider.LazyEndPosition = currCursorPosition; + LazyEndPosition = currCursorPosition; } } - private Vector2 getEndCursorPosition(OsuHitObject hitObject) + private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject) { - Vector2 pos = hitObject.StackedPosition; - - if (hitObject is Slider slider) - { - computeSliderCursorPosition(slider); - pos = slider.LazyEndPosition ?? pos; - } - - return pos; + return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition; } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index faf91e4652..5816d27a5e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -2,9 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -13,19 +17,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Aim : OsuStrainSkill { - public Aim(Mod[] mods, bool withSliders) + public readonly bool IncludeSliders; + + public Aim(Mod[] mods, bool includeSliders) : base(mods) { - this.withSliders = withSliders; + IncludeSliders = includeSliders; } - private readonly bool withSliders; - private double currentStrain; - private double skillMultiplier => 25.18; + private double skillMultiplier => 26; private double strainDecayBase => 0.15; + private readonly List sliderStrains = new List(); + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); @@ -33,9 +39,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier; + currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier; + + if (current.BaseObject is Slider) + sliderStrains.Add(currentStrain); return currentStrain; } + + public double GetDifficultSliders() + { + if (sliderStrains.Count == 0) + return 0; + + double maxSliderStrain = sliderStrains.Max(); + + if (maxSliderStrain == 0) + return 0; + + return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); + } + + public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index d2c4bbb618..8fe3df4347 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -2,11 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; using System.Linq; +using osu.Game.Rulesets.Osu.Difficulty.Utils; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -15,12 +18,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : OsuStrainSkill { - private double skillMultiplier => 1.430; + private double skillMultiplier => 1.47; private double strainDecayBase => 0.3; private double currentStrain; private double currentRhythm; + private readonly List sliderStrains = new List(); + protected override int ReducedSectionCount => 5; public Speed(Mod[] mods) @@ -34,13 +39,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime); - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); double totalStrain = currentStrain * currentRhythm; + if (current.BaseObject is Slider) + sliderStrains.Add(totalStrain); + return totalStrain; } @@ -55,5 +63,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } + + public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs new file mode 100644 index 0000000000..df1683fb29 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs @@ -0,0 +1,101 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Utils +{ + public static class LegacyScoreUtils + { + /// + /// Calculates the average amount of score per object that is caused by nested judgements such as slider-ticks and spinners. + /// + public static double CalculateNestedScorePerObject(IBeatmap beatmap, int objectCount) + { + const double big_tick_score = 30; + const double small_tick_score = 10; + + var sliders = beatmap.HitObjects.OfType().ToArray(); + + // 1 for head, 1 for tail + int amountOfBigTicks = sliders.Length * 2; + + // Add slider repeats + amountOfBigTicks += sliders.Select(s => s.RepeatCount).Sum(); + + int amountOfSmallTicks = sliders.Select(s => s.NestedHitObjects.Count(nho => nho is SliderTick)).Sum(); + + double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; + + double spinnerScore = 0; + + foreach (var spinner in beatmap.HitObjects.OfType()) + { + spinnerScore += calculateSpinnerScore(spinner); + } + + return (sliderScore + spinnerScore) / objectCount; + } + + /// + /// Logic borrowed from for basic score calculations. + /// + private static double calculateSpinnerScore(Spinner spinner) + { + const int spin_score = 100; + const int bonus_spin_score = 1000; + + // The spinner object applies a lenience because gameplay mechanics differ from osu-stable. + // We'll redo the calculations to match osu-stable here... + const double maximum_rotations_per_second = 477.0 / 60; + + // Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score. + // As we're primarily concerned with computing the maximum theoretical final score, + // this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1. + const double minimum_rotations_per_second = 3; + + double secondsDuration = spinner.Duration / 1000; + + // The total amount of half spins possible for the entire spinner. + int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2); + // The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). + int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second); + // To be able to receive bonus points, the spinner must be rotated another 1.5 times. + int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3; + + long score = 0; + + int fullSpins = (totalHalfSpinsPossible / 2); + + // Normal spin score + score += spin_score * fullSpins; + + int bonusSpins = (totalHalfSpinsPossible - halfSpinsRequiredBeforeBonus) / 2; + + // Reduce amount of bonus spins because we want to represent the more average case, rather than the best one. + bonusSpins = Math.Max(0, bonusSpins - fullSpins / 2); + + score += bonus_spin_score * bonusSpins; + + return score; + } + + public static int CalculateDifficultyPeppyStars(IBeatmap beatmap) + { + int objectCount = beatmap.HitObjects.Count; + int drainLength = 0; + + if (objectCount > 0) + { + int breakLength = beatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum(); + drainLength = ((int)Math.Round(beatmap.HitObjects[^1].StartTime) - (int)Math.Round(beatmap.HitObjects[0].StartTime) - breakLength) / 1000; + } + + return LegacyRulesetExtensions.CalculateDifficultyPeppyStars(beatmap.Difficulty, objectCount, drainLength); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs new file mode 100644 index 0000000000..8a78192ee4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Difficulty.Utils; + +namespace osu.Game.Rulesets.Osu.Difficulty.Utils +{ + public static class OsuStrainUtils + { + public static double CountTopWeightedSliders(IReadOnlyCollection sliderStrains, double difficultyValue) + { + if (sliderStrains.Count == 0) + return 0; + + double consistentTopStrain = difficultyValue / 10; // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return 0; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs index 163b42bcfd..f54dc2c85b 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints public partial class GridPlacementBlueprint : PlacementBlueprint { [Resolved] - private HitObjectComposer? hitObjectComposer { get; set; } + private OsuHitObjectComposer? hitObjectComposer { get; set; } private OsuGridToolboxGroup gridToolboxGroup = null!; private Vector2 originalOrigin; @@ -36,9 +36,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints base.EndPlacement(commit); - // You typically only place the grid once, so we switch back to the last tool after placement. - if (commit && hitObjectComposer is OsuHitObjectComposer osuHitObjectComposer) - osuHitObjectComposer.SetLastTool(); + // You typically only place the grid once, so we switch back to the last tool after placement - + // but only if the tool hasn't changed from under us (which is possible, as external tool changes will commit any ongoing placements, including this one) + if (commit && hitObjectComposer?.BlueprintContainer.CurrentTool is GridFromPointsTool) + hitObjectComposer.SetLastTool(); } protected override bool OnClick(ClickEvent e) @@ -95,12 +96,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints base.OnDragEnd(e); } - public override SnapType SnapType => ~SnapType.GlobalGrids; - - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { if (State.Value == Visibility.Hidden) - return; + return new SnapResult(screenSpacePosition, fallbackTime); + + var result = hitObjectComposer?.TrySnapToNearbyObjects(screenSpacePosition) ?? new SnapResult(screenSpacePosition, fallbackTime); var pos = ToLocalSpace(result.ScreenSpacePosition); @@ -120,6 +121,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints gridToolboxGroup.SetGridFromPoints(gridToolboxGroup.StartPosition.Value, pos); } } + + return result; } protected override void PopOut() diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs index 8ed9d0476a..7a5b01ce79 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -76,7 +77,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components if (hasReachedObject && showHitMarkers.Value) { float alpha = Interpolation.ValueAt(editorTime, 0, 1f, hitObjectTime, hitObjectTime + FADE_OUT_EXTENSION, Easing.In); - float ringScale = MathHelper.Clamp(Interpolation.ValueAt(editorTime, 0, 1f, hitObjectTime, hitObjectTime + FADE_OUT_EXTENSION / 2, Easing.OutQuint), 0, 1); + float ringScale = Math.Clamp(Interpolation.ValueAt(editorTime, 0, 1f, hitObjectTime, hitObjectTime + FADE_OUT_EXTENSION / 2, Easing.OutQuint), 0, 1); ring.Scale = new Vector2(1 + 0.1f * ringScale); content.Alpha = 0.9f * (1 - alpha); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index 78a0e36dc2..61ed30259a 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -1,10 +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.Framework.Bindables; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles @@ -15,12 +20,26 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles private readonly HitCirclePiece circlePiece; + [Resolved] + private OsuHitObjectComposer? composer { get; set; } + + [Resolved] + private EditorClock? editorClock { get; set; } + + private Bindable limitedDistanceSnap { get; set; } = null!; + public HitCirclePlacementBlueprint() : base(new HitCircle()) { InternalChild = circlePiece = new HitCirclePiece(); } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + limitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -45,10 +64,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles return base.OnMouseDown(e); } - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { - base.UpdateTimeAndPosition(result); + var result = composer?.TrySnapToNearbyObjects(screenSpacePosition, fallbackTime); + result ??= composer?.TrySnapToDistanceGrid(screenSpacePosition, limitedDistanceSnap.Value && editorClock != null ? editorClock.CurrentTime : null); + if (composer?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? screenSpacePosition, result?.Time ?? fallbackTime) is SnapResult gridSnapResult) + result = gridSnapResult; + result ??= new SnapResult(screenSpacePosition, fallbackTime); + + base.UpdateTimeAndPosition(result.ScreenSpacePosition, result.Time ?? fallbackTime); HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); + return result; } } } 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 f114516300..bff6701826 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -9,6 +9,7 @@ using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using Humanizer; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -20,6 +21,7 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -32,7 +34,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { public partial class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu - where T : OsuHitObject, IHasPath + where T : OsuHitObject, IHasPath, IHasSliderVelocity { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside the playfield. @@ -48,11 +50,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public Action> SplitControlPointsRequested; [Resolved(CanBeNull = true)] - private IPositionSnapProvider positionSnapProvider { get; set; } + [CanBeNull] + private OsuHitObjectComposer positionSnapProvider { get; set; } [Resolved(CanBeNull = true)] private IDistanceSnapProvider distanceSnapProvider { get; set; } + private Bindable limitedDistanceSnap { get; set; } = null!; + public PathControlPointVisualiser(T hitObject, bool allowSelection) { this.hitObject = hitObject; @@ -67,6 +72,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components }; } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + limitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -137,11 +148,27 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// /// Delete all visually selected s. /// - /// + /// Whether any change actually took place. public bool DeleteSelected() { List toRemove = Pieces.Where(p => p.IsSelected.Value).Select(p => p.ControlPoint).ToList(); + if (!Delete(toRemove)) + return false; + + // Since pieces are re-used, they will not point to the deleted control points while remaining selected + foreach (var piece in Pieces) + piece.IsSelected.Value = false; + + return true; + } + + /// + /// Delete the specified s. + /// + /// Whether any change actually took place. + public bool Delete(List toRemove) + { // Ensure that there are any points to be deleted if (toRemove.Count == 0) return false; @@ -149,11 +176,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components changeHandler?.BeginChange(); RemoveControlPointsRequested?.Invoke(toRemove); changeHandler?.EndChange(); - - // Since pieces are re-used, they will not point to the deleted control points while remaining selected - foreach (var piece in Pieces) - piece.IsSelected.Value = false; - return true; } @@ -422,12 +444,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { // Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); - SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition); - Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position; + var result = positionSnapProvider?.TrySnapToNearbyObjects(newHeadPosition, oldStartTime); + result ??= positionSnapProvider?.TrySnapToDistanceGrid(newHeadPosition, limitedDistanceSnap.Value ? oldStartTime : null); + if (positionSnapProvider?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? newHeadPosition, result?.Time ?? oldStartTime) is SnapResult gridSnapResult) + result = gridSnapResult; + result ??= new SnapResult(newHeadPosition, oldStartTime); + + Vector2 movementDelta = Parent!.ToLocalSpace(result.ScreenSpacePosition) - hitObject.Position; hitObject.Position += movementDelta; - hitObject.StartTime = result?.Time ?? hitObject.StartTime; + hitObject.StartTime = result.Time ?? hitObject.StartTime; for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++) { @@ -442,9 +469,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { - SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); + Vector2 newControlPointPosition = Parent!.ToScreenSpace(e.MousePosition); - Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; + // Snapping inherited B-spline control points to nearby objects would be unintuitive, because snapping them does not equate to snapping the interpolated slider path. + bool shouldSnapToNearbyObjects = dragPathTypes[draggedControlPointIndex] is not null || + dragPathTypes[..draggedControlPointIndex].LastOrDefault(t => t is not null)?.Type != SplineType.BSpline; + + SnapResult result = null; + if (shouldSnapToNearbyObjects) + result = positionSnapProvider?.TrySnapToNearbyObjects(newControlPointPosition, oldStartTime); + if (positionSnapProvider?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? newControlPointPosition, result?.Time ?? oldStartTime) is SnapResult gridSnapResult) + result = gridSnapResult; + result ??= new SnapResult(newControlPointPosition, oldStartTime); + + Vector2 movementDelta = Parent!.ToLocalSpace(result.ScreenSpacePosition) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; for (int i = 0; i < controlPoints.Count; ++i) { @@ -457,7 +495,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components // Snap the path to the current beat divisor before checking length validity. hitObject.SnapTo(distanceSnapProvider); - if (!hitObject.Path.HasValidLength) + if (!hitObject.Path.HasValidLengthForPlacement) { for (int i = 0; i < hitObject.Path.ControlPoints.Count; i++) hitObject.Path.ControlPoints[i].Position = oldControlPoints[i]; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs index 37383544dc..9cc5394191 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs @@ -10,6 +10,7 @@ using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Osu.Objects; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { @@ -76,6 +77,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders base.OnDragEnd(e); } + protected override bool OnMouseDown(MouseDownEvent e) => e.Button == MouseButton.Left; + + protected override bool OnClick(ClickEvent e) => e.Button == MouseButton.Left; + private void updateState() { Colour = IsHovered || IsDragged ? colours.Red : colours.Yellow; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 4f2f6516a8..d934eb5a9e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -5,10 +5,12 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -25,6 +27,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public new Slider HitObject => (Slider)base.HitObject; + [Resolved] + private OsuHitObjectComposer? composer { get; set; } + private SliderBodyPiece bodyPiece = null!; private HitCirclePiece headCirclePiece = null!; private HitCirclePiece tailCirclePiece = null!; @@ -40,18 +45,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private int currentSegmentLength; private bool usingCustomSegmentType; - [Resolved] - private IPositionSnapProvider? positionSnapProvider { get; set; } - [Resolved] private IDistanceSnapProvider? distanceSnapProvider { get; set; } [Resolved] private FreehandSliderToolboxGroup? freehandToolboxGroup { get; set; } + [Resolved] + private EditorClock? editorClock { get; set; } + + private Bindable limitedDistanceSnap { get; set; } = null!; + private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 }; - protected override bool IsValidForPlacement => HitObject.Path.HasValidLength; + protected override bool IsValidForPlacement => HitObject.Path.HasValidLengthForPlacement; public SliderPlacementBlueprint() : base(new Slider()) @@ -63,7 +70,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { InternalChildren = new Drawable[] { @@ -74,6 +81,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders }; state = SliderPlacementState.Initial; + limitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); } protected override void LoadComplete() @@ -106,9 +114,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders [Resolved] private EditorBeatmap editorBeatmap { get; set; } = null!; - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { - base.UpdateTimeAndPosition(result); + var result = composer?.TrySnapToNearbyObjects(screenSpacePosition, fallbackTime); + result ??= composer?.TrySnapToDistanceGrid(screenSpacePosition, limitedDistanceSnap.Value && editorClock != null ? editorClock.CurrentTime : null); + if (composer?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? screenSpacePosition, result?.Time ?? fallbackTime) is SnapResult gridSnapResult) + result = gridSnapResult; + result ??= new SnapResult(screenSpacePosition, fallbackTime); + + base.UpdateTimeAndPosition(result.ScreenSpacePosition, result.Time ?? fallbackTime); switch (state) { @@ -131,6 +145,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders updateCursor(); break; } + + return result; } protected override bool OnMouseDown(MouseDownEvent e) @@ -375,7 +391,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private Vector2 getCursorPosition() { - var result = positionSnapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.ControlPoints ? SnapType.GlobalGrids : SnapType.All); + SnapResult? result = null; + var mousePosition = inputManager.CurrentState.Mouse.Position; + + if (state != SliderPlacementState.ControlPoints) + { + result ??= composer?.TrySnapToNearbyObjects(mousePosition); + result ??= composer?.TrySnapToDistanceGrid(mousePosition); + } + + result ??= composer?.TrySnapToPositionGrid(mousePosition); + return ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position; } @@ -408,7 +434,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders if (state == SliderPlacementState.Drawing) HitObject.Path.ExpectedDistance.Value = (float)HitObject.Path.CalculatedDistance; else - HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? (float)HitObject.Path.CalculatedDistance; + HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance((float)HitObject.Path.CalculatedDistance, HitObject.StartTime, HitObject) ?? (float)HitObject.Path.CalculatedDistance; bodyPiece.UpdateFrom(HitObject); headCirclePiece.UpdateFrom(HitObject.HeadCircle); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 34de81f1ba..b46dd44ce5 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -140,8 +140,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders if (hoveredControlPoint == null) return false; - hoveredControlPoint.IsSelected.Value = true; - ControlPointVisualiser?.DeleteSelected(); + if (hoveredControlPoint.IsSelected.Value) + ControlPointVisualiser?.DeleteSelected(); + else + ControlPointVisualiser?.Delete([hoveredControlPoint.ControlPoint]); + return true; } @@ -267,14 +270,21 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders if (adjustVelocity) { proposedVelocity = proposedDistance / oldDuration; - proposedDistance = MathHelper.Clamp(proposedDistance, 0.1 * oldDuration, 10 * oldDuration); + proposedDistance = Math.Clamp(proposedDistance, 0.1 * oldDuration, 10 * oldDuration); } else { - double minDistance = distanceSnapProvider?.GetBeatSnapDistanceAt(HitObject, false) * oldVelocityMultiplier ?? 1; + double minDistance = distanceSnapProvider?.GetBeatSnapDistance() * oldVelocityMultiplier ?? 1; + // do not allow the slider to extend beyond the path's calculated distance. + // this can happen in two specific circumstances: + // - floating point issues (`minDistance` is just ever so slightly larger than the calculated distance) + // - the slider was placed with a higher beat snap active than the current one, + // therefore snapping it to the current beat snap distance would mean extrapolating it beyond its actual shape as defined by its control points + minDistance = Math.Min(minDistance, HitObject.Path.CalculatedDistance); + // Add a small amount to the proposed distance to make it easier to snap to the full length of the slider. - proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1, DistanceSnapTarget.Start) ?? proposedDistance; - proposedDistance = MathHelper.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance); + proposedDistance = distanceSnapProvider?.FindSnappedDistance((float)proposedDistance + 1, HitObject.StartTime, HitObject) ?? proposedDistance; + proposedDistance = Math.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance); } if (Precision.AlmostEquals(proposedDistance, HitObject.Path.Distance) && Precision.AlmostEquals(proposedVelocity, HitObject.SliderVelocityMultiplier)) @@ -473,7 +483,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders HitObject.SnapTo(distanceSnapProvider); // If there are 0 or 1 remaining control points, or the slider has an invalid length, it is in a degenerate form and should be deleted - if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength) + if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLengthForPlacement) { placementHandler?.Delete(HitObject); return; @@ -551,6 +561,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders HitObject.Position += first; } + // duplicated in `JuiceStreamSelectionBlueprint.convertToStream()` + // consider extracting common helper when applying changes here private void convertToStream() { if (editorBeatmap == null || beatDivisor == null) @@ -614,14 +626,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation); - protected override Vector2[] ScreenSpaceAdditionalNodes => new[] - { + protected override Vector2[] ScreenSpaceAdditionalNodes => getScreenSpaceControlPointNodes().Prepend( DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathEndOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathEndLocation) - }; + ).ToArray(); + + private IEnumerable getScreenSpaceControlPointNodes() + { + // Returns the positions of control points that produce visible kinks on the slider's path + // This excludes inherited control points from Bezier, B-Spline, Perfect, and Catmull curves + if (DrawableObject.SliderBody == null) + yield break; + + PathType? currentPathType = null; + + // Skip the last control point because its always either not on the slider path or exactly on the slider end + for (int i = 0; i < DrawableObject.HitObject.Path.ControlPoints.Count - 1; i++) + { + var controlPoint = DrawableObject.HitObject.Path.ControlPoints[i]; + + if (controlPoint.Type is not null) + currentPathType = controlPoint.Type; + + // Skip the first control point because it is already covered by the slider head + if (i == 0) + continue; + + if (controlPoint.Type is null && currentPathType != PathType.LINEAR) + continue; + + var screenSpacePosition = DrawableObject.SliderBody.ToScreenSpace(DrawableObject.SliderBody.PathOffset + controlPoint.Position); + yield return screenSpacePosition; + } + } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) { - if (BodyPiece.ReceivePositionalInputAt(screenSpacePos)) + if (BodyPiece.ReceivePositionalInputAt(screenSpacePos) && (IsSelected || DrawableObject.Body.Alpha > 0 || DrawableObject.HeadCircle.Alpha > 0)) return true; if (ControlPointVisualiser == null) diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs index 084a3e5ea1..565499fc58 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks if (context.InterpretedDifficulty > DifficultyRating.Easy) yield break; - var hitObjects = context.Beatmap.HitObjects; + var hitObjects = context.CurrentDifficulty.Playable.HitObjects; for (int i = 0; i < hitObjects.Count - 1; ++i) { diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs index a342c2a821..6910c721ac 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - foreach (var hitobject in context.Beatmap.HitObjects) + foreach (var hitobject in context.CurrentDifficulty.Playable.HitObjects) { switch (hitobject) { diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuAbnormalDifficultySettings.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuAbnormalDifficultySettings.cs index 1c44d54633..a53c6bf7a1 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuAbnormalDifficultySettings.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuAbnormalDifficultySettings.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks public override IEnumerable Run(BeatmapVerifierContext context) { - var diff = context.Beatmap.Difficulty; + var diff = context.CurrentDifficulty.Playable.Difficulty; Issue? issue; if (HasMoreThanOneDecimalPlace("Approach rate", diff.ApproachRate, out issue)) diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs new file mode 100644 index 0000000000..283f3b93af --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.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 System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; + +namespace osu.Game.Rulesets.Osu.Edit.Checks +{ + public class CheckOsuLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21#general + yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert"); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs index 585bd35bd9..ac3faa1a09 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs @@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks yield break; var prevObservedTimeDistances = new List(); - var hitObjects = context.Beatmap.HitObjects; + var hitObjects = context.CurrentDifficulty.Playable.HitObjects; for (int i = 0; i < hitObjects.Count - 1; ++i) { diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs index 159498c479..8752920b44 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks if (context.InterpretedDifficulty > DifficultyRating.Easy) yield break; - foreach (var hitObject in context.Beatmap.HitObjects) + foreach (var hitObject in context.CurrentDifficulty.Playable.HitObjects) { if (hitObject is Slider slider && slider.SpanDuration < span_duration_threshold) yield return new IssueTemplateTooShort(this).Create(slider); diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs index f0aade1b7f..a1ba0cf530 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs @@ -19,14 +19,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - double od = context.Beatmap.Difficulty.OverallDifficulty; + double od = context.CurrentDifficulty.Playable.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) + foreach (var hitObject in context.CurrentDifficulty.Playable.HitObjects) { if (!(hitObject is Spinner spinner)) continue; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs index 4b01a1fc39..67fddfb8a4 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Rulesets.Osu.Edit.Checks; @@ -16,11 +17,13 @@ namespace osu.Game.Rulesets.Osu.Edit // Compose new CheckOffscreenObjects(), new CheckTooShortSpinners(), + new CheckConcurrentObjects(), // Spread new CheckTimeDistanceEquality(), new CheckLowDiffOverlaps(), new CheckTooShortSliders(), + new CheckOsuLowestDiffDrainTime(), // Settings new CheckOsuAbnormalDifficultySettings(), diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs index 54c54fca17..9d82046c23 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs @@ -1,6 +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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; @@ -8,16 +14,27 @@ using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; namespace osu.Game.Rulesets.Osu.Edit { public partial class OsuBlueprintContainer : ComposeBlueprintContainer { - public OsuBlueprintContainer(HitObjectComposer composer) + private Bindable limitedDistanceSnap { get; set; } = null!; + + public new OsuHitObjectComposer Composer => (OsuHitObjectComposer)base.Composer; + + public OsuBlueprintContainer(OsuHitObjectComposer composer) : base(composer) { } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + limitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); + } + protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); public override HitObjectSelectionBlueprint? CreateHitObjectBlueprintFor(HitObject hitObject) @@ -36,5 +53,68 @@ namespace osu.Game.Rulesets.Osu.Edit return base.CreateHitObjectBlueprintFor(hitObject); } + + protected override bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints) + { + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + for (int i = 0; i < blueprints.Count; i++) + { + if (checkSnappingBlueprintToNearbyObjects(blueprints[i].blueprint, distanceTravelled, blueprints[i].originalSnapPositions)) + return true; + } + + // if no positional snapping could be performed, try unrestricted snapping from the earliest + // item in the selection. + + // The final movement position, relative to movementBlueprintOriginalPosition. + Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled; + var referenceBlueprint = blueprints.First().blueprint; + + // Retrieve a snapped position. + var result = Composer.TrySnapToNearbyObjects(movePosition); + result ??= Composer.TrySnapToDistanceGrid(movePosition, limitedDistanceSnap.Value ? referenceBlueprint.Item.StartTime : null); + if (Composer.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? movePosition, result?.Time) is SnapResult gridSnapResult) + result = gridSnapResult; + result ??= new SnapResult(movePosition, null); + + bool moved = SelectionHandler.HandleMovement(new MoveSelectionEvent(referenceBlueprint, result.ScreenSpacePosition - referenceBlueprint.ScreenSpaceSelectionPoint)); + if (moved) + ApplySnapResultTime(result, referenceBlueprint.Item.StartTime); + return moved; + } + + /// + /// Check for positional snap for given blueprint. + /// + /// The blueprint to check for snapping. + /// Distance travelled since start of dragging action. + /// The snap positions of blueprint before start of dragging action. + /// Whether an object to snap to was found. + private bool checkSnappingBlueprintToNearbyObjects(SelectionBlueprint blueprint, Vector2 distanceTravelled, Vector2[] originalPositions) + { + var currentPositions = blueprint.ScreenSpaceSnapPoints; + + for (int i = 0; i < originalPositions.Length; i++) + { + Vector2 originalPosition = originalPositions[i]; + var testPosition = originalPosition + distanceTravelled; + + var positionalResult = Composer.TrySnapToNearbyObjects(testPosition); + + if (positionalResult == null || positionalResult.ScreenSpacePosition == testPosition) continue; + + var delta = positionalResult.ScreenSpacePosition - currentPositions[i]; + + // attempt to move the objects, and apply any time based snapping if we can. + if (SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprint, delta))) + { + ApplySnapResultTime(positionalResult, blueprint.Item.StartTime); + return true; + } + } + + return false; + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs index 848c994974..3323acce15 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs @@ -5,6 +5,7 @@ using JetBrains.Annotations; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; @@ -12,8 +13,8 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class OsuDistanceSnapGrid : CircularDistanceSnapGrid { - public OsuDistanceSnapGrid(OsuHitObject hitObject, [CanBeNull] OsuHitObject nextHitObject = null) - : base(hitObject, hitObject.StackedEndPosition, hitObject.GetEndTime(), nextHitObject?.StartTime - 1) + public OsuDistanceSnapGrid(OsuHitObject hitObject, [CanBeNull] OsuHitObject nextHitObject = null, [CanBeNull] IHasSliderVelocity sliderVelocitySource = null) + : base(hitObject.StackedEndPosition, hitObject.GetEndTime(), nextHitObject?.StartTime - 1, sliderVelocitySource) { Masking = true; } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs index 4042cfa0e2..6be60e4554 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.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.Linq; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osuTK; @@ -14,8 +17,15 @@ namespace osu.Game.Rulesets.Osu.Edit { public override double ReadCurrentDistanceSnap(HitObject before, HitObject after) { - float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); - float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position); + // If the pair of hit objects in question here could feasibly be on the same stack, do not provide a distance snap value - + // they're likely too close to one another for the distance snap value to be useful anyway even if they somehow are not. + if (Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position) < OsuBeatmapProcessor.STACK_DISTANCE) + return 0; + + var lastObjectWithVelocity = EditorBeatmap.HitObjects.TakeWhile(ho => ho != after).OfType().LastOrDefault(); + + float expectedDistance = DurationToDistance(after.StartTime - before.GetEndTime(), before.StartTime, lastObjectWithVelocity); + float actualDistance = Vector2.Distance(((OsuHitObject)before).StackedEndPosition, ((OsuHitObject)after).StackedPosition); return actualDistance / expectedDistance; } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs index 6220fa66b1..991d42c7b4 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Edit public BindableFloat Spacing { get; } = new BindableFloat(4f) { MinValue = 4f, - MaxValue = 128f, + MaxValue = 256f, Precision = 0.01f, }; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 7c50558b92..e4f8ee5b6d 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; @@ -22,15 +23,18 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit { + [Cached] public partial class OsuHitObjectComposer : HitObjectComposer { public OsuHitObjectComposer(Ruleset ruleset) @@ -53,9 +57,14 @@ namespace osu.Game.Rulesets.Osu.Edit protected override Drawable CreateHitObjectInspector() => new OsuHitObjectInspector(); - protected override IEnumerable CreateTernaryButtons() + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons() - .Append(new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap })) + .Append(new DrawableTernaryButton + { + Current = rectangularGridSnapToggle, + Description = "Grid Snap", + CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap }, + }) .Concat(DistanceSnapProvider.CreateTernaryButtons()); private BindableList selectedHitObjects; @@ -173,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Edit return; List remainingHitObjects = EditorBeatmap.HitObjects.Cast().Where(h => h.StartTime >= timestamp).ToList(); - string[] splitDescription = objectDescription.Split(',').ToArray(); + string[] splitDescription = objectDescription.Split(','); for (int i = 0; i < splitDescription.Length; i++) { @@ -217,56 +226,60 @@ namespace osu.Game.Rulesets.Osu.Edit } } - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) + [CanBeNull] + public SnapResult TrySnapToNearbyObjects(Vector2 screenSpacePosition, double? fallbackTime = null) { - if (snapType.HasFlag(SnapType.NearbyObjects) && snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) - { - // In the case of snapping to nearby objects, a time value is not provided. - // This matches the stable editor (which also uses current time), but with the introduction of time-snapping distance snap - // this could result in unexpected behaviour when distance snapping is turned on and a user attempts to place an object that is - // BOTH on a valid distance snap ring, and also at the same position as a previous object. - // - // We want to ensure that in this particular case, the time-snapping component of distance snap is still applied. - // The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over - // the time value if the proposed positions are roughly the same. - if (snapType.HasFlag(SnapType.RelativeGrids) && DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) - { - (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition)); - if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1)) - snapResult.Time = distanceSnappedTime; - } + if (!snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) + return null; + if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True || distanceSnapGrid == null) return snapResult; - } - SnapResult result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); + // In the case of snapping to nearby objects, a time value is not provided. + // This matches the stable editor (which also uses current time), but with the introduction of time-snapping distance snap + // this could result in unexpected behaviour when distance snapping is turned on and a user attempts to place an object that is + // BOTH on a valid distance snap ring, and also at the same position as a previous object. + // + // We want to ensure that in this particular case, the time-snapping component of distance snap is still applied. + // The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over + // the time value if the proposed positions are roughly the same. + (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition)); + snapResult.Time = Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1) + ? distanceSnappedTime + : fallbackTime; - if (snapType.HasFlag(SnapType.RelativeGrids)) - { - if (DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) - { - (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + return snapResult; + } - result.ScreenSpacePosition = distanceSnapGrid.ToScreenSpace(pos); - result.Time = time; - } - } + [CanBeNull] + public SnapResult TrySnapToDistanceGrid(Vector2 screenSpacePosition, double? fixedTime = null) + { + if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True || distanceSnapGrid?.IsLoaded != true) + return null; - if (snapType.HasFlag(SnapType.GlobalGrids)) - { - if (rectangularGridSnapToggle.Value == TernaryState.True) - { - Vector2 pos = positionSnapGrid.GetSnappedPosition(positionSnapGrid.ToLocalSpace(result.ScreenSpacePosition)); + var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); + (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition), fixedTime); - // A grid which doesn't perfectly fit the playfield can produce a position that is outside of the playfield. - // We need to clamp the position to the playfield bounds to ensure that the snapped position is always in bounds. - pos = Vector2.Clamp(pos, Vector2.Zero, OsuPlayfield.BASE_SIZE); + if (pos.X < 0 || pos.X > OsuPlayfield.BASE_SIZE.X || pos.Y < 0 || pos.Y > OsuPlayfield.BASE_SIZE.Y) + return null; - result.ScreenSpacePosition = positionSnapGrid.ToScreenSpace(pos); - } - } + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, playfield); + } - return result; + [CanBeNull] + public SnapResult TrySnapToPositionGrid(Vector2 screenSpacePosition, double? fallbackTime = null) + { + if (rectangularGridSnapToggle.Value != TernaryState.True) + return null; + + Vector2 pos = positionSnapGrid.GetSnappedPosition(positionSnapGrid.ToLocalSpace(screenSpacePosition)); + + // A grid which doesn't perfectly fit the playfield can produce a position that is outside of the playfield. + // We need to clamp the position to the playfield bounds to ensure that the snapped position is always in bounds. + pos = Vector2.Clamp(pos, Vector2.Zero, OsuPlayfield.BASE_SIZE); + + var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); + return new SnapResult(positionSnapGrid.ToScreenSpace(pos), fallbackTime, playfield); } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) @@ -343,6 +356,35 @@ namespace osu.Game.Rulesets.Osu.Edit } } + protected override bool OnMouseDown(MouseDownEvent e) + { + // Why is this logic here and not in `OsuSelectionHandler`? + // Because we only want to handle this toggle after all other right-click handling completes. + // + // Consider that input is handled from the most nested child first: + // + // ComposeScreen + // |- OsuContextMenuContainer // right click for context + // |- TimelineBlueprintContainer + // |- TimelineSelectionHandler + // |- (Osu)HitObjectComposer // right click for toggle new combo + // |- (Osu)EditorBlueprintContainer // right click for select + // |- (Osu)EditorSelectionHandler // right click for delete + if (e.Button == MouseButton.Right) + { + var osuSelectionHandler = (OsuSelectionHandler)BlueprintContainer.SelectionHandler; + + if (!osuSelectionHandler.SelectedItems.Any()) + { + osuSelectionHandler.SelectionNewComboState.Value = + osuSelectionHandler.SelectionNewComboState.Value == TernaryState.False ? TernaryState.True : TernaryState.False; + return true; + } + } + + return base.OnMouseDown(e); + } + protected override bool OnKeyDown(KeyDownEvent e) { if (e.Repeat) @@ -399,22 +441,26 @@ namespace osu.Game.Rulesets.Osu.Edit { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(targetOffset); - int sourceIndex = -1; + int positionSourceObjectIndex = -1; + IHasSliderVelocity sliderVelocitySource = null; for (int i = 0; i < EditorBeatmap.HitObjects.Count; i++) { if (!sourceSelector(EditorBeatmap.HitObjects[i])) break; - sourceIndex = i; + positionSourceObjectIndex = i; + + if (EditorBeatmap.HitObjects[i] is IHasSliderVelocity hasSliderVelocity) + sliderVelocitySource = hasSliderVelocity; } - if (sourceIndex == -1) + if (positionSourceObjectIndex == -1) return null; - HitObject sourceObject = EditorBeatmap.HitObjects[sourceIndex]; + HitObject sourceObject = EditorBeatmap.HitObjects[positionSourceObjectIndex]; - int targetIndex = sourceIndex + targetOffset; + int targetIndex = positionSourceObjectIndex + targetOffset; HitObject targetObject = null; // Keep advancing the target object while its start time falls before the end time of the source object @@ -435,7 +481,7 @@ namespace osu.Game.Rulesets.Osu.Edit if (sourceObject is Spinner) return null; - return new OsuDistanceSnapGrid((OsuHitObject)sourceObject, (OsuHitObject)targetObject); + return new OsuDistanceSnapGrid((OsuHitObject)sourceObject, (OsuHitObject)targetObject, sliderVelocitySource); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index bac0a5e273..c591b79b29 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -40,6 +40,8 @@ namespace osu.Game.Rulesets.Osu.Edit SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider); } + private bool nudgeMovementActive; + protected override bool OnKeyDown(KeyDownEvent e) { if (e.Key == Key.M && e.ControlPressed && e.ShiftPressed) @@ -48,9 +50,43 @@ namespace osu.Game.Rulesets.Osu.Edit return true; } + // Until the keys below are global actions, this will prevent conflicts with "seek between sample points" + // which has a default of ctrl+shift+arrows. + if (e.ShiftPressed) + return false; + + if (e.ControlPressed) + { + switch (e.Key) + { + case Key.Left: + return nudgeSelection(new Vector2(-1, 0)); + + case Key.Right: + return nudgeSelection(new Vector2(1, 0)); + + case Key.Up: + return nudgeSelection(new Vector2(0, -1)); + + case Key.Down: + return nudgeSelection(new Vector2(0, 1)); + } + } + return false; } + protected override void OnKeyUp(KeyUpEvent e) + { + base.OnKeyUp(e); + + if (nudgeMovementActive && !e.ControlPressed) + { + EditorBeatmap.EndChange(); + nudgeMovementActive = false; + } + } + public override bool HandleMovement(MoveSelectionEvent moveEvent) { var hitObjects = selectedMovableObjects; @@ -70,6 +106,13 @@ namespace osu.Game.Rulesets.Osu.Edit if (hitObjects.Any(h => Precision.AlmostEquals(localDelta, -h.StackOffset))) return true; + moveObjects(hitObjects, localDelta); + + return true; + } + + private void moveObjects(OsuHitObject[] hitObjects, Vector2 localDelta) + { // this will potentially move the selection out of bounds... foreach (var h in hitObjects) h.Position += localDelta; @@ -81,7 +124,26 @@ namespace osu.Game.Rulesets.Osu.Edit // this intentionally bypasses the editor `UpdateState()` / beatmap processor flow for performance reasons, // as the entire flow is too expensive to run on every movement. Scheduler.AddOnce(OsuBeatmapProcessor.ApplyStacking, EditorBeatmap); + } + /// + /// Move the current selection spatially by the specified delta, in gamefield coordinates (ie. the same coordinates as the blueprints). + /// + /// + private bool nudgeSelection(Vector2 delta) + { + if (!nudgeMovementActive) + { + nudgeMovementActive = true; + EditorBeatmap.BeginChange(); + } + + var firstBlueprint = SelectedBlueprints.FirstOrDefault(); + + if (firstBlueprint == null) + return false; + + moveObjects(selectedMovableObjects, delta); return true; } @@ -191,7 +253,7 @@ namespace osu.Game.Rulesets.Osu.Edit { var hitObjects = selectedMovableObjects; - Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects); + Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects, true); Vector2 delta = Vector2.Zero; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index e3ab95c402..d5f3137769 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -81,12 +81,8 @@ namespace osu.Game.Rulesets.Osu.Edit changeHandler?.BeginChange(); objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho)); - OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider - ? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position)) - : GeometryUtils.GetSurroundingQuad(objectsInScale.Keys); - originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2 - ? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position)) - : GeometryUtils.GetConvexHull(objectsInScale.Keys); + OriginalSurroundingQuad = GeometryUtils.GetSurroundingQuad(objectsInScale.Keys); + originalConvexHull = GeometryUtils.GetConvexHull(objectsInScale.Keys); defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1; } @@ -180,7 +176,7 @@ namespace osu.Game.Rulesets.Osu.Edit Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider }); (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad); - if (xInBounds && yInBounds && slider.Path.HasValidLength) + if (xInBounds && yInBounds && slider.Path.HasValidLengthForPlacement) return; for (int i = 0; i < slider.Path.ControlPoints.Count; i++) @@ -263,12 +259,12 @@ namespace osu.Game.Rulesets.Osu.Edit { case Axes.X: (sLowerBound, sUpperBound) = computeBounds(lowerBounds - b, upperBounds - b, a); - s.X = MathHelper.Clamp(s.X, sLowerBound, sUpperBound); + s.X = Math.Clamp(s.X, sLowerBound, sUpperBound); break; case Axes.Y: (sLowerBound, sUpperBound) = computeBounds(lowerBounds - a, upperBounds - a, b); - s.Y = MathHelper.Clamp(s.Y, sLowerBound, sUpperBound); + s.Y = Math.Clamp(s.Y, sLowerBound, sUpperBound); break; case Axes.Both: @@ -276,11 +272,11 @@ namespace osu.Game.Rulesets.Osu.Edit // Therefore the ratio s.X / s.Y will be maintained (sLowerBound, sUpperBound) = computeBounds(lowerBounds, upperBounds, a * s.X + b * s.Y); s.X = s.X < 0 - ? MathHelper.Clamp(s.X, s.X * sUpperBound, s.X * sLowerBound) - : MathHelper.Clamp(s.X, s.X * sLowerBound, s.X * sUpperBound); + ? Math.Clamp(s.X, s.X * sUpperBound, s.X * sLowerBound) + : Math.Clamp(s.X, s.X * sLowerBound, s.X * sUpperBound); s.Y = s.Y < 0 - ? MathHelper.Clamp(s.Y, s.Y * sUpperBound, s.Y * sLowerBound) - : MathHelper.Clamp(s.Y, s.Y * sLowerBound, s.Y * sUpperBound); + ? Math.Clamp(s.Y, s.Y * sUpperBound, s.Y * sLowerBound) + : Math.Clamp(s.Y, s.Y * sLowerBound, s.Y * sUpperBound); break; } @@ -312,7 +308,7 @@ namespace osu.Game.Rulesets.Osu.Edit private void moveSelectionInBounds() { - Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys); + Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys, true); Vector2 delta = Vector2.Zero; diff --git a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs index 695ff516b1..046f57c0a5 100644 --- a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs @@ -103,7 +103,7 @@ namespace osu.Game.Rulesets.Osu.Edit Current = new BindableNumber(3) { MinValue = 3, - MaxValue = 10, + MaxValue = 32, Precision = 1, }, Instantaneous = true diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs new file mode 100644 index 0000000000..f3739ab445 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs @@ -0,0 +1,208 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Input.Events; +using osu.Game.Extensions; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class PreciseMovementPopover : OsuPopover + { + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + private readonly Dictionary initialPositions = new Dictionary(); + private RectangleF initialSurroundingQuad; + + private BindableNumber xBindable = null!; + private BindableNumber yBindable = null!; + + private SliderWithTextBoxInput xInput = null!; + private OsuCheckbox relativeCheckbox = null!; + + public PreciseMovementPopover() + { + AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + Width = 220, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(20), + Children = new Drawable[] + { + xInput = new SliderWithTextBoxInput("X:") + { + Current = xBindable = new BindableNumber + { + Precision = 1, + }, + Instantaneous = true, + TabbableContentContainer = this, + }, + new SliderWithTextBoxInput("Y:") + { + Current = yBindable = new BindableNumber + { + Precision = 1, + }, + Instantaneous = true, + TabbableContentContainer = this, + }, + relativeCheckbox = new OsuCheckbox(false) + { + RelativeSizeAxes = Axes.X, + LabelText = "Relative movement", + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => xInput.TakeFocus()); + } + + protected override void PopIn() + { + base.PopIn(); + editorBeatmap.BeginChange(); + initialPositions.AddRange(editorBeatmap.SelectedHitObjects.Where(ho => ho is not Spinner).Select(ho => new KeyValuePair(ho, ((IHasPosition)ho).Position))); + initialSurroundingQuad = GeometryUtils.GetSurroundingQuad(initialPositions.Keys.Cast()).AABBFloat; + + Debug.Assert(initialPositions.Count > 0); + + if (initialPositions.Count > 1) + { + relativeCheckbox.Current.Value = true; + relativeCheckbox.Current.Disabled = true; + } + + relativeCheckbox.Current.BindValueChanged(_ => relativeChanged(), true); + xBindable.BindValueChanged(_ => applyPosition()); + yBindable.BindValueChanged(_ => applyPosition()); + } + + protected override void PopOut() + { + base.PopOut(); + if (IsLoaded) editorBeatmap.EndChange(); + } + + private void relativeChanged() + { + // reset bindable bounds to something that is guaranteed to be larger than any previous value. + // this prevents crashes that can happen in the middle of changing the bounds, as updating both bound ends at the same is not atomic - + // if the old and new bounds are disjoint, assigning X first can produce a situation where MinValue > MaxValue. + (xBindable.MinValue, xBindable.MaxValue) = (float.MinValue, float.MaxValue); + (yBindable.MinValue, yBindable.MaxValue) = (float.MinValue, float.MaxValue); + + float previousX = xBindable.Value; + float previousY = yBindable.Value; + + if (relativeCheckbox.Current.Value) + { + xBindable.MinValue = 0 - Math.Max(initialSurroundingQuad.TopLeft.X, 0); + xBindable.MaxValue = OsuPlayfield.BASE_SIZE.X - Math.Min(initialSurroundingQuad.BottomRight.X, OsuPlayfield.BASE_SIZE.X); + + yBindable.MinValue = 0 - Math.Max(initialSurroundingQuad.TopLeft.Y, 0); + yBindable.MaxValue = OsuPlayfield.BASE_SIZE.Y - Math.Min(initialSurroundingQuad.BottomRight.Y, OsuPlayfield.BASE_SIZE.Y); + + xBindable.Default = yBindable.Default = 0; + + if (initialPositions.Count == 1) + { + var initialPosition = initialPositions.Single().Value; + xBindable.Value = previousX - initialPosition.X; + yBindable.Value = previousY - initialPosition.Y; + } + } + else + { + Debug.Assert(initialPositions.Count == 1); + var initialPosition = initialPositions.Single().Value; + + var quadRelativeToPosition = new RectangleF(initialSurroundingQuad.Location - initialPosition, initialSurroundingQuad.Size); + + if (initialSurroundingQuad.Width < OsuPlayfield.BASE_SIZE.X) + { + xBindable.MinValue = 0 - quadRelativeToPosition.TopLeft.X; + xBindable.MaxValue = OsuPlayfield.BASE_SIZE.X - quadRelativeToPosition.BottomRight.X; + } + else + xBindable.MinValue = xBindable.MaxValue = initialPosition.X; + + if (initialSurroundingQuad.Height < OsuPlayfield.BASE_SIZE.Y) + { + yBindable.MinValue = 0 - quadRelativeToPosition.TopLeft.Y; + yBindable.MaxValue = OsuPlayfield.BASE_SIZE.Y - quadRelativeToPosition.BottomRight.Y; + } + else + yBindable.MinValue = yBindable.MaxValue = initialPosition.Y; + + xBindable.Default = initialPosition.X; + yBindable.Default = initialPosition.Y; + + xBindable.Value = xBindable.Default + previousX; + yBindable.Value = yBindable.Default + previousY; + } + } + + private void applyPosition() + { + // can happen if popover is dismissed by a keyboard key press while dragging UI controls + // it doesn't cause a crash, but it looks wrong + if (!editorBeatmap.TransactionActive) + return; + + editorBeatmap.PerformOnSelection(ho => + { + if (!initialPositions.TryGetValue(ho, out var initialPosition)) + return; + + var pos = new Vector2(xBindable.Value, yBindable.Value); + if (relativeCheckbox.Current.Value) + ((IHasPosition)ho).Position = initialPosition + pos; + else + ((IHasPosition)ho).Position = pos; + }); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + this.HidePopover(); + return true; + } + + return base.OnPressed(e); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index 477d3b4e57..e2cde1a325 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -96,11 +96,7 @@ namespace osu.Game.Rulesets.Osu.Edit { base.LoadComplete(); - ScheduleAfterChildren(() => - { - angleInput.TakeFocus(); - angleInput.SelectAll(); - }); + ScheduleAfterChildren(() => angleInput.TakeFocus()); angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue }); rotationHandler.CanRotateAroundSelectionOrigin.BindValueChanged(e => @@ -161,6 +157,10 @@ namespace osu.Game.Rulesets.Osu.Edit rotationInfo.BindValueChanged(rotation => { + // can happen if the popover is dismissed by a keyboard key press while dragging UI controls + if (!rotationHandler.OperationInProgress.Value) + return; + rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue)); }); } diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index e728290289..ca4a99b9cd 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -139,11 +139,7 @@ namespace osu.Game.Rulesets.Osu.Edit { base.LoadComplete(); - ScheduleAfterChildren(() => - { - scaleInput.TakeFocus(); - scaleInput.SelectAll(); - }); + ScheduleAfterChildren(() => scaleInput.TakeFocus()); scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue }); xCheckBox.Current.BindValueChanged(_ => @@ -224,6 +220,10 @@ namespace osu.Game.Rulesets.Osu.Edit scaleInfo.BindValueChanged(scale => { + // can happen if the popover is dismissed by a keyboard key press while dragging UI controls + if (!scaleHandler.OperationInProgress.Value) + return; + var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale); scaleHandler.Update(newScale, getOriginPosition(scale.NewValue), getAdjustAxis(scale.NewValue), getRotation(scale.NewValue)); }); diff --git a/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs b/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs index 45e3f3ac49..3ed1d82883 100644 --- a/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs +++ b/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs @@ -91,6 +91,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup { Caption = EditorSetupStrings.BaseVelocity, HintText = EditorSetupStrings.BaseVelocityDescription, + KeyboardStep = 0.1f, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, @@ -105,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup { Caption = EditorSetupStrings.TickRate, HintText = EditorSetupStrings.TickRateDescription, + KeyboardStep = 1, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, @@ -119,6 +121,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup { Caption = "Stack Leniency", HintText = "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.", + KeyboardStep = 0.1f, Current = new BindableFloat(Beatmap.StackLeniency) { Default = 0.7f, diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs index a41412cbe3..440e06598d 100644 --- a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,6 +11,9 @@ 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 osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -18,9 +22,12 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler { + private readonly BindableList selectedHitObjects = new BindableList(); + private readonly BindableBool canMove = new BindableBool(); private readonly AggregateBindable canRotate = new AggregateBindable((x, y) => x || y); private readonly AggregateBindable canScale = new AggregateBindable((x, y) => x || y); + private EditorToolButton moveButton = null!; private EditorToolButton rotateButton = null!; private EditorToolButton scaleButton = null!; @@ -35,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Edit } [BackgroundDependencyLoader] - private void load() + private void load(EditorBeatmap editorBeatmap) { Child = new FillFlowContainer { @@ -44,20 +51,27 @@ namespace osu.Game.Rulesets.Osu.Edit Spacing = new Vector2(5), Children = new Drawable[] { + moveButton = new EditorToolButton("Move", + () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, + () => new PreciseMovementPopover()), rotateButton = new EditorToolButton("Rotate", () => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, () => new PreciseRotationPopover(RotationHandler, GridToolbox)), scaleButton = new EditorToolButton("Scale", - () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, + () => new SpriteIcon { Icon = FontAwesome.Solid.ExpandArrowsAlt }, () => new PreciseScalePopover(ScaleHandler, GridToolbox)) } }; + + selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects); } protected override void LoadComplete() { base.LoadComplete(); + selectedHitObjects.BindCollectionChanged((_, _) => canMove.Value = selectedHitObjects.Any(ho => ho is not Spinner), true); + canRotate.AddSource(RotationHandler.CanRotateAroundPlayfieldOrigin); canRotate.AddSource(RotationHandler.CanRotateAroundSelectionOrigin); @@ -67,6 +81,7 @@ namespace osu.Game.Rulesets.Osu.Edit // bindings to `Enabled` on the buttons are decoupled on purpose // due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set. + canMove.BindValueChanged(move => moveButton.Enabled.Value = move.NewValue, true); canRotate.Result.BindValueChanged(rotate => rotateButton.Enabled.Value = rotate.NewValue, true); canScale.Result.BindValueChanged(scale => scaleButton.Enabled.Value = scale.NewValue, true); } @@ -77,6 +92,12 @@ namespace osu.Game.Rulesets.Osu.Edit switch (e.Action) { + case GlobalAction.EditorToggleMoveControl: + { + moveButton.TriggerClick(); + return true; + } + case GlobalAction.EditorToggleRotateControl: { if (!RotationHandler.OperationInProgress.Value || rotateButton.Selected.Value) diff --git a/osu.Game.Rulesets.Osu/HUD/AimErrorMeter.cs b/osu.Game.Rulesets.Osu/HUD/AimErrorMeter.cs new file mode 100644 index 0000000000..46593a56bb --- /dev/null +++ b/osu.Game.Rulesets.Osu/HUD/AimErrorMeter.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.ComponentModel; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation.HUD; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; +using osuTK.Graphics; +using Container = osu.Framework.Graphics.Containers.Container; + +namespace osu.Game.Rulesets.Osu.HUD +{ + [Cached] + public partial class AimErrorMeter : HitErrorMeter + { + [SettingSource(typeof(AimErrorMeterStrings), nameof(AimErrorMeterStrings.HitMarkerSize), nameof(AimErrorMeterStrings.HitMarkerSizeDescription))] + public BindableNumber HitMarkerSize { get; } = new BindableNumber(7f) + { + MinValue = 0f, + MaxValue = 12f, + Precision = 1f + }; + + [SettingSource(typeof(AimErrorMeterStrings), nameof(AimErrorMeterStrings.HitMarkerStyle), nameof(AimErrorMeterStrings.HitMarkerStyleDescription))] + public Bindable HitMarkerStyle { get; } = new Bindable(); + + [SettingSource(typeof(AimErrorMeterStrings), nameof(AimErrorMeterStrings.AverageMarkerSize), nameof(AimErrorMeterStrings.AverageMarkerSizeDescription))] + public BindableNumber AverageMarkerSize { get; } = new BindableNumber(12f) + { + MinValue = 7f, + MaxValue = 25f, + Precision = 1f + }; + + [SettingSource(typeof(AimErrorMeterStrings), nameof(AimErrorMeterStrings.AverageMarkerStyle), nameof(AimErrorMeterStrings.AverageMarkerStyleDescription))] + public Bindable AverageMarkerStyle { get; } = new Bindable(MarkerStyle.Plus); + + [SettingSource(typeof(AimErrorMeterStrings), nameof(AimErrorMeterStrings.PositionDisplayStyle), nameof(AimErrorMeterStrings.PositionDisplayStyleDescription))] + public Bindable PositionDisplayStyle { get; } = new Bindable(); + + // used for calculate relative position. + private Vector2? lastObjectPosition; + + private Container averagePositionMarker = null!; + private Container averagePositionMarkerRotationContainer = null!; + private Vector2? averagePosition; + + private readonly DrawablePool hitPositionPool = new DrawablePool(30); + private Container hitPositionMarkerContainer = null!; + + private Container arrowBackgroundContainer = null!; + private UprightAspectMaintainingContainer rotateFixedContainer = null!; + private Container mainContainer = null!; + + private float objectRadius; + + private const int max_concurrent_judgements = 30; + + private const float line_thickness = 2; + private const float inner_portion = 0.85f; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public AimErrorMeter() + { + AutoSizeAxes = Axes.Both; + AlwaysPresent = true; + } + + [BackgroundDependencyLoader] + private void load(IBindable beatmap, ScoreProcessor processor) + { + InternalChild = new Container + { + Height = 100, + Width = 100, + Children = new Drawable[] + { + hitPositionPool, + rotateFixedContainer = new UprightAspectMaintainingContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + }; + + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BorderColour = Colour4.White, + Masking = true, + BorderThickness = 2, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(inner_portion), + Child = new Box + { + Colour = Colour4.Gray, + Alpha = 0.3f, + RelativeSizeAxes = Axes.Both + }, + }, + arrowBackgroundContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Name = "Arrow Background", + RelativeSizeAxes = Axes.Both, + Rotation = 45, + Alpha = 0f, + Children = new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = inner_portion + 0.2f, + Width = line_thickness / 2, + }, + new Circle + { + Height = 5f, + Width = line_thickness / 2, + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(-line_thickness / 4), + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + 0.2f) / 2, + Rotation = -45 + }, + new Circle + { + Height = 5f, + Width = line_thickness / 2, + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(-line_thickness / 4), + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + 0.2f) / 2, + Rotation = 45 + } + } + }, + new Container + { + Name = "Cross Background", + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.5f, + Width = line_thickness, + Height = inner_portion * 0.9f + }, + new Circle + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.5f, + Width = line_thickness, + Height = inner_portion * 0.9f, + Rotation = 90 + }, + new Circle + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Width = line_thickness / 2, + Height = inner_portion * 0.9f, + Rotation = 45 + }, + new Circle + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Width = line_thickness / 2, + Height = inner_portion * 0.9f, + Rotation = 135 + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + hitPositionMarkerContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }, + averagePositionMarker = new UprightAspectMaintainingContainer + { + RelativePositionAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = averagePositionMarkerRotationContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.25f, + }, + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.25f, + Rotation = 90 + } + } + } + } + } + } + } + }; + + // handle IApplicableToDifficulty for CS change. + BeatmapDifficulty newDifficulty = new BeatmapDifficulty(); + beatmap.Value.Beatmap.Difficulty.CopyTo(newDifficulty); + + var mods = processor.Mods.Value; + + foreach (var mod in mods.OfType()) + mod.ApplyToDifficulty(newDifficulty); + + objectRadius = OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(newDifficulty.CircleSize, true); + + AverageMarkerSize.BindValueChanged(size => averagePositionMarker.Size = new Vector2(size.NewValue), true); + AverageMarkerStyle.BindValueChanged(style => averagePositionMarkerRotationContainer.Rotation = style.NewValue == MarkerStyle.Plus ? 0 : 45, true); + + PositionDisplayStyle.BindValueChanged(s => + { + Clear(); + + if (s.NewValue == PositionDisplay.Normalised) + { + arrowBackgroundContainer.FadeIn(100); + rotateFixedContainer.Remove(mainContainer, false); + AddInternal(mainContainer); + } + else + { + arrowBackgroundContainer.FadeOut(100); + // when in absolute mode, rotation of the aim error meter as a whole should not affect how the component is displayed + RemoveInternal(mainContainer, false); + rotateFixedContainer.Add(mainContainer); + } + }, true); + } + + protected override void OnNewJudgement(JudgementResult judgement) + { + if (judgement is not OsuHitCircleJudgementResult circleJudgement) return; + + if (circleJudgement.CursorPositionAtHit == null) return; + + if (hitPositionMarkerContainer.Count > max_concurrent_judgements) + { + const double quick_fade_time = 300; + + // check with a bit of lenience to avoid precision error in comparison. + var old = hitPositionMarkerContainer.FirstOrDefault(j => j.LifetimeEnd > Clock.CurrentTime + quick_fade_time * 1.1); + + if (old != null) + { + old.ClearTransforms(); + old.FadeOut(quick_fade_time).Expire(); + } + } + + Vector2 hitPosition; + + if (PositionDisplayStyle.Value == PositionDisplay.Normalised && lastObjectPosition != null) + { + hitPosition = AccuracyHeatmap.FindRelativeHitPosition(lastObjectPosition.Value, ((OsuHitObject)circleJudgement.HitObject).StackedEndPosition, + circleJudgement.CursorPositionAtHit.Value, objectRadius, 45) * (inner_portion / 2); + } + else + { + // get relative position between mouse position and current object. + hitPosition = (circleJudgement.CursorPositionAtHit.Value - ((OsuHitObject)circleJudgement.HitObject).StackedPosition) / objectRadius / 2 * inner_portion; + } + + hitPosition = Vector2.Clamp(hitPosition, new Vector2(-0.5f), new Vector2(0.5f)); + + hitPositionPool.Get(drawableHit => + { + drawableHit.X = hitPosition.X; + drawableHit.Y = hitPosition.Y; + drawableHit.Colour = getColourForPosition(hitPosition); + + hitPositionMarkerContainer.Add(drawableHit); + }); + + var newAveragePosition = 0.1f * hitPosition + 0.9f * (averagePosition ?? hitPosition); + averagePositionMarker.MoveTo(newAveragePosition, 800, Easing.OutQuint); + averagePosition = newAveragePosition; + lastObjectPosition = ((OsuHitObject)circleJudgement.HitObject).StackedPosition; + } + + private Color4 getColourForPosition(Vector2 position) + { + float distance = Vector2.Distance(position, Vector2.Zero); + + if (distance >= 0.5f * inner_portion) + return colours.Red; + + if (distance >= 0.35f * inner_portion) + return colours.Yellow; + + if (distance >= 0.2f * inner_portion) + return colours.Green; + + return colours.Blue; + } + + public override void Clear() + { + averagePosition = null; + averagePositionMarker.MoveTo(Vector2.Zero, 800, Easing.OutQuint); + lastObjectPosition = null; + + foreach (var h in hitPositionMarkerContainer) + { + h.ClearTransforms(); + h.Expire(); + } + } + + private partial class HitPositionMarker : PoolableDrawable + { + [Resolved] + private AimErrorMeter aimErrorMeter { get; set; } = null!; + + public readonly BindableNumber MarkerSize = new BindableFloat(); + public readonly Bindable Style = new Bindable(); + + private readonly Container content; + + public HitPositionMarker() + { + RelativePositionAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChild = new UprightAspectMaintainingContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.25f, + Rotation = -45 + }, + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.25f, + Rotation = 45 + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + MarkerSize.BindTo(aimErrorMeter.HitMarkerSize); + MarkerSize.BindValueChanged(size => Size = new Vector2(size.NewValue), true); + Style.BindTo(aimErrorMeter.HitMarkerStyle); + Style.BindValueChanged(style => content.Rotation = style.NewValue == MarkerStyle.X ? 0 : 45, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + const int judgement_fade_in_duration = 100; + const int judgement_fade_out_duration = 5000; + + this + .ResizeTo(new Vector2(0)) + .FadeInFromZero(judgement_fade_in_duration, Easing.OutQuint) + .ResizeTo(new Vector2(MarkerSize.Value), judgement_fade_in_duration, Easing.OutQuint) + .Then() + .FadeOut(judgement_fade_out_duration) + .Expire(); + } + } + + public enum MarkerStyle + { + [Description("x")] + X, + + [Description("+")] + Plus, + } + + public enum PositionDisplay + { + [LocalisableDescription(typeof(AimErrorMeterStrings), nameof(AimErrorMeterStrings.Absolute))] + Absolute, + + [LocalisableDescription(typeof(AimErrorMeterStrings), nameof(AimErrorMeterStrings.Normalised))] + Normalised, + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs index b56fdbdf74..34eb2be077 100644 --- a/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs +++ b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs @@ -67,6 +67,9 @@ namespace osu.Game.Rulesets.Osu.Mods { if (LastAcceptedAction != null && nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) LastAcceptedAction = null; + + if (LastAcceptedAction != null && gameplayClock.IsRewinding) + LastAcceptedAction = null; } protected abstract bool CheckValidNewAction(OsuAction action); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs index 269da46fca..f1a56fb1a2 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Mods { @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => @"Alternate"; public override string Acronym => @"AL"; public override LocalisableString Description => @"Don't use the same key twice in a row!"; - public override IconUsage? Icon => FontAwesome.Solid.Keyboard; + public override IconUsage? Icon => OsuIcon.ModAlternate; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModSingleTap) }).ToArray(); public override bool Ranked => true; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs index f213d9f193..033ab0f861 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -19,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "AD"; public override LocalisableString Description => "Never trust the approach circles..."; public override double ScoreMultiplier => 1; - public override IconUsage? Icon { get; } = FontAwesome.Regular.Circle; + public override IconUsage? Icon => OsuIcon.ModApproachDifferent; public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModFreezeFrame) }; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index bb0e984418..97d76459c6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; @@ -26,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override LocalisableString Description => "Play with blinds on your screen."; public override string Acronym => "BL"; - public override IconUsage? Icon => FontAwesome.Solid.Adjust; + public override IconUsage? Icon => OsuIcon.ModBlinds; public override ModType Type => ModType.DifficultyIncrease; public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs index c674074dc6..445fb8b37a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs @@ -3,9 +3,11 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; @@ -21,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Bloom"; public override string Acronym => "BM"; + public override IconUsage? Icon => OsuIcon.ModBloom; public override ModType Type => ModType.Fun; public override LocalisableString Description => "The cursor blooms into.. a larger cursor!"; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs index b34cc29741..b706e07a55 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs @@ -11,7 +11,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -34,6 +36,8 @@ namespace osu.Game.Rulesets.Osu.Mods public override double ScoreMultiplier => 1; + public override IconUsage? Icon => OsuIcon.ModBubbles; + public override ModType Type => ModType.Fun; // Compatibility with these seems potentially feasible in the future, blocked for now because they don't work as one would expect diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs index f6622c268d..faceb2ac7c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs @@ -4,6 +4,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Mods { @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "DF"; - public override IconUsage? Icon => FontAwesome.Solid.CompressArrowsAlt; + public override IconUsage? Icon => OsuIcon.ModDeflate; public override LocalisableString Description => "Hit them at the right size!"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs index 306dcee839..ea0be78c09 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Depth"; public override string Acronym => "DP"; - public override IconUsage? Icon => FontAwesome.Solid.Cube; + public override IconUsage? Icon => OsuIcon.ModDepth; public override ModType Type => ModType.Fun; public override LocalisableString Description => "3D. Almost."; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index f35b1abc42..0d6b02a7d1 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.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 System.Linq; +using System.Collections.Generic; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -37,19 +37,37 @@ namespace osu.Game.Rulesets.Osu.Mods ReadCurrentFromDifficulty = diff => diff.ApproachRate, }; - public override string SettingDescription + public override string ExtendedIconInformation { get { - string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}"; - string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}"; + if (!IsExactlyOneSettingChanged(CircleSize, ApproachRate, OverallDifficulty, DrainRate)) + return string.Empty; - return string.Join(", ", new[] - { - circleSize, - base.SettingDescription, - approachRate - }.Where(s => !string.IsNullOrEmpty(s))); + if (!CircleSize.IsDefault) return format("CS", CircleSize); + if (!ApproachRate.IsDefault) return format("AR", ApproachRate); + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); + if (!DrainRate.IsDefault) return format("HP", DrainRate); + + return string.Empty; + + string format(string acronym, DifficultyBindable bindable) + => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; + } + } + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!CircleSize.IsDefault) + yield return ("Circle size", $"{CircleSize.Value:N1}"); + + foreach (var setting in base.SettingDescription) + yield return setting; + + if (!ApproachRate.IsDefault) + yield return ("Approach rate", $"{ApproachRate.Value:N1}"); } } @@ -63,13 +81,7 @@ namespace osu.Game.Rulesets.Osu.Mods private partial class ApproachRateSettingsControl : DifficultyAdjustSettingsControl { - protected override RoundedSliderBar CreateSlider(BindableNumber current) => - new ApproachRateSlider - { - RelativeSizeAxes = Axes.X, - Current = current, - KeyboardStep = 0.1f, - }; + protected override RoundedSliderBar CreateSlider(BindableNumber current) => new ApproachRateSlider(); /// /// A slider bar with more detailed approach rate info for its given value diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs index 281b36e70e..9725a42674 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs @@ -2,12 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { public class OsuModEasy : ModEasyWithExtraLives { - public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and extra lives!"; + + public override void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + base.ApplyToDifficulty(difficulty); + + difficulty.OverallDifficulty *= ADJUST_RATIO; + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 3009530b50..a8c2508f80 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 200; + public override float DefaultFlashlightSize => 125; private OsuFlashlight flashlight = null!; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs index 06cb9c3419..e75ed24a7d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs @@ -4,8 +4,10 @@ using System; using System.Linq; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -19,6 +21,8 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "FR"; + public override IconUsage? Icon => OsuIcon.ModFreezeFrame; + public override double ScoreMultiplier => 1; public override LocalisableString Description => "Burn the notes into your memory."; @@ -43,7 +47,10 @@ namespace osu.Game.Rulesets.Osu.Mods foreach (var obj in beatmap.HitObjects.OfType()) { - if (obj.NewCombo) { lastNewComboTime = obj.StartTime; } + if (obj.NewCombo) + { + lastNewComboTime = obj.StartTime; + } applyFadeInAdjustment(obj); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs index 3d066d3ada..475089ba94 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs @@ -4,16 +4,17 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModGrow : OsuModObjectScaleTween + public class OsuModGrow : OsuModObjectScaleTween { public override string Name => "Grow"; public override string Acronym => "GR"; - public override IconUsage? Icon => FontAwesome.Solid.ArrowsAltV; + public override IconUsage? Icon => OsuIcon.ModGrow; public override LocalisableString Description => "Hit them at the right size!"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index d24597eeed..e7ac63599d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs @@ -28,6 +28,7 @@ namespace osu.Game.Rulesets.Osu.Mods { base.ApplyToDifficulty(difficulty); + difficulty.OverallDifficulty = Math.Min(difficulty.OverallDifficulty * ADJUST_RATIO, 10.0f); difficulty.CircleSize = Math.Min(difficulty.CircleSize * 1.3f, 10.0f); // CS uses a custom 1.3 ratio. difficulty.ApproachRate = Math.Min(difficulty.ApproachRate * ADJUST_RATIO, 10.0f); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index b2553e295c..11b512c882 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -9,6 +9,7 @@ using osu.Framework.Localisation; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -19,11 +20,11 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + public class OsuModMagnetised : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset { public override string Name => "Magnetised"; public override string Acronym => "MG"; - public override IconUsage? Icon => FontAwesome.Solid.Magnet; + public override IconUsage? Icon => OsuIcon.ModMagnetised; public override ModType Type => ModType.Fun; public override LocalisableString Description => "No need to chase the circles – your cursor is a magnet!"; public override double ScoreMultiplier => 0.5; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs index 6d01808fb5..4af88caee4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override LocalisableString Description => "Flip objects on the chosen axes."; public override Type[] IncompatibleMods => new[] { typeof(ModHardRock) }; - [SettingSource("Mirrored axes", "Choose which axes objects are mirrored over.")] + [SettingSource("Flipped axes")] public Bindable Reflection { get; } = new Bindable(); public void ApplyToHitObject(HitObject hitObject) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 31511c01b8..71de3c269b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Mods /// /// How early before a hitobject's start time to trigger a hit. /// - private const float relax_leniency = 3; + public const float RELAX_LENIENCY = 12; private bool isDownState; private bool wasLeft; @@ -83,7 +83,7 @@ namespace osu.Game.Rulesets.Osu.Mods foreach (var h in playfield.HitObjectContainer.AliveObjects.OfType()) { // we are not yet close enough to the object. - if (time < h.HitObject.StartTime - relax_leniency) + if (time < h.HitObject.StartTime - RELAX_LENIENCY) break; // already hit or beyond the hittable end time. diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs index 302e17432e..b95cc9b651 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs @@ -4,10 +4,12 @@ using System; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -19,10 +21,11 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModRepel : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + public class OsuModRepel : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset { public override string Name => "Repel"; public override string Acronym => "RP"; + public override IconUsage? Icon => OsuIcon.ModRepel; public override ModType Type => ModType.Fun; public override LocalisableString Description => "Hit objects run away!"; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs index 0d9c7d4afe..2fc646846d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs @@ -3,7 +3,9 @@ using System; using System.Linq; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Mods { @@ -11,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => @"Single Tap"; public override string Acronym => @"SG"; + public override IconUsage? Icon => OsuIcon.ModSingleTap; public override LocalisableString Description => @"You must only use one key!"; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAlternate) }).ToArray(); public override bool Ranked => true; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs index 59a1342480..429332bc55 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Spin In"; public override string Acronym => "SI"; - public override IconUsage? Icon => FontAwesome.Solid.Undo; + public override IconUsage? Icon => OsuIcon.ModSpinIn; public override ModType Type => ModType.Fun; public override LocalisableString Description => "Circles spin in. No approach circles."; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index 992f4d5f03..222cf4242a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Mods { var spinner = (DrawableSpinner)drawable; - spinner.RotationTracker.Tracking = true; + spinner.RotationTracker.Tracking = spinner.RotationTracker.IsSpinnableTime; // early-return if we were paused to avoid division-by-zero in the subsequent calculations. if (Precision.AlmostEquals(spinner.Clock.Rate, 0)) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 7d2fd628f6..16ef639384 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -4,18 +4,21 @@ using System; using System.Linq; using System.Threading; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Graphics; 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.Objects.Types; using osu.Game.Rulesets.Osu.Beatmaps; -using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.Osu.Mods { @@ -23,6 +26,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => @"Strict Tracking"; public override string Acronym => @"ST"; + public override IconUsage? Icon => OsuIcon.ModStrictTracking; public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => @"Once you start a slider, follow precisely or get a miss."; public override double ScoreMultiplier => 1.0; @@ -39,6 +43,9 @@ namespace osu.Game.Rulesets.Osu.Mods if (slider.Time.Current < slider.HitObject.StartTime) return; + if ((slider.Clock as IGameplayClock)?.IsRewinding == true) + return; + var tail = slider.NestedHitObjects.OfType().First(); if (!tail.Judged) @@ -79,7 +86,12 @@ namespace osu.Game.Rulesets.Osu.Mods { } - public override Judgement CreateJudgement() => new OsuJudgement(); + public override Judgement CreateJudgement() => new StrictTrackingTailJudgement(); + } + + public class StrictTrackingTailJudgement : SliderTailCircle.TailJudgement + { + public override HitResult MinResult => HitResult.LargeTickMiss; } private partial class StrictTrackingDrawableSliderTail : DrawableSliderTail diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs index e661610fe7..d331b691d5 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs @@ -3,7 +3,12 @@ using System; using System.Linq; +using osu.Framework.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Mods { @@ -13,5 +18,19 @@ namespace osu.Game.Rulesets.Osu.Mods { typeof(OsuModTargetPractice), }).ToArray(); + + [SettingSource("Also fail when missing a slider tail")] + public BindableBool FailOnSliderTail { get; } = new BindableBool(); + + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + { + if (base.FailCondition(healthProcessor, result)) + return true; + + if (FailOnSliderTail.Value && result.HitObject is SliderTailCircle && !result.IsHit) + return true; + + return false; + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs index a5846efdfe..e82ec2fb10 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => "Target Practice"; public override string Acronym => "TP"; public override ModType Type => ModType.Conversion; - public override IconUsage? Icon => OsuIcon.ModTarget; + public override IconUsage? Icon => OsuIcon.ModTargetPractice; public override LocalisableString Description => @"Practice keeping up with the beat of the song."; public override double ScoreMultiplier => 0.1; @@ -115,10 +115,6 @@ namespace osu.Game.Rulesets.Osu.Mods #region Reduce AR (IApplicableToDifficulty) - public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) - { - } - public void ApplyToDifficulty(BeatmapDifficulty difficulty) { // Decrease AR to increase preempt time @@ -230,7 +226,7 @@ namespace osu.Game.Rulesets.Osu.Mods // 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(); + obj.Samples = samples ?? getClosestHitObject(originalHitObjects, obj.StartTime).Samples.Where(s => !HitSampleInfo.ALL_ADDITIONS.Contains(s.Name)).ToList(); } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index 9091837034..b2a3da285c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -4,7 +4,9 @@ using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -18,6 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Traceable"; public override string Acronym => "TC"; + public override IconUsage? Icon => OsuIcon.ModTraceable; public override ModType Type => ModType.Fun; public override LocalisableString Description => "Put your faith in the approach circles..."; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index b6907af119..d0a1350db9 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -14,11 +15,11 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModTransform : ModWithVisibilityAdjustment + public class OsuModTransform : ModWithVisibilityAdjustment { public override string Name => "Transform"; public override string Acronym => "TR"; - public override IconUsage? Icon => FontAwesome.Solid.ArrowsAlt; + public override IconUsage? Icon => OsuIcon.ModTransform; public override ModType Type => ModType.Fun; public override LocalisableString Description => "Everything rotates. EVERYTHING."; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index d14a821541..7c0faab235 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; @@ -15,11 +16,11 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModWiggle : ModWithVisibilityAdjustment + public class OsuModWiggle : ModWithVisibilityAdjustment { public override string Name => "Wiggle"; public override string Acronym => "WG"; - public override IconUsage? Icon => FontAwesome.Solid.Certificate; + public override IconUsage? Icon => OsuIcon.ModWiggle; public override ModType Type => ModType.Fun; public override LocalisableString Description => "They just won't stay still..."; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index b3a68ec92d..b29be97951 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -149,5 +150,24 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected float CalculateDrawableRelativePosition(Drawable drawable) => (drawable.ScreenSpaceDrawQuad.Centre.X - parentScreenSpaceRectangle.X) / parentScreenSpaceRectangle.Width; protected override JudgementResult CreateResult(Judgement judgement) => new OsuJudgementResult(HitObject, judgement); + + protected void ApplyRepeatFadeIn(Drawable target, double fadeTime) + { + DrawableSlider slider = (DrawableSlider)ParentHitObject; + int repeatIndex = ((SliderEndCircle)HitObject).RepeatIndex; + + Debug.Assert(slider != null); + + // When snaking in is enabled, the first end circle needs to be delayed until the snaking completes. + bool delayFadeIn = slider.SliderBody?.SnakingIn.Value == true && repeatIndex == 0; + + if (repeatIndex > 0) + fadeTime = Math.Min(slider.HitObject.SpanDuration, fadeTime); + + target + .FadeOut() + .Delay(delayFadeIn ? (slider.HitObject.TimePreempt) / 3 : 0) + .FadeIn(fadeTime); + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 8b3fcb23cd..0f7812d91a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [Resolved] private OsuConfigManager config { get; set; } = null!; - private Vector2 screenSpacePosition; + private Vector2? screenSpacePosition; [BackgroundDependencyLoader] private void load() @@ -65,7 +65,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Lighting.ResetAnimation(); Lighting.SetColourFrom(this, Result); - Position = Parent!.ToLocalSpace(screenSpacePosition); + + if (screenSpacePosition != null) + Position = Parent!.ToLocalSpace(screenSpacePosition.Value); } protected override void ApplyHitAnimations() @@ -87,7 +89,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.ApplyHitAnimations(); } - protected override Drawable CreateDefaultJudgement(HitResult result) => new OsuJudgementPiece(result); + protected override Drawable CreateDefaultJudgement(HitResult result) => + // Tick hits don't show a judgement by default + result.IsHit() && result.IsTick() ? Empty() : new OsuJudgementPiece(result); private partial class OsuJudgementPiece : DefaultJudgementPiece { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index eacd2b3e75..e22e1d2001 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -377,13 +377,34 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { UpdateState(ArmedState.Idle); HeadCircle.SuppressHitAnimations(); + + foreach (var repeat in repeatContainer) + repeat.SuppressHitAnimations(); + TailCircle.SuppressHitAnimations(); + + // This method is called every frame in editor contexts, thus the lack of need for transforms. + + if (Time.Current >= HitStateUpdateTime) + { + // Apply the slider's alpha to *only* the body. + // This allows start and – more importantly – end circles to fade slower than the overall slider. + if (Alpha < 1) + Body.Alpha = Alpha; + Alpha = 1; + } + + LifetimeEnd = HitStateUpdateTime + 700; } internal void RestoreHitAnimations() { UpdateState(ArmedState.Hit); HeadCircle.RestoreHitAnimations(); + + foreach (var repeat in repeatContainer) + repeat.RestoreHitAnimations(); + TailCircle.RestoreHitAnimations(); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 24c0d0fcf0..9b8b197804 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -66,8 +66,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Slider slider = drawableSlider.HitObject; Position = slider.CurvePositionAt(completionProgress); - //0.1 / slider.Path.Distance is the additional progress needed to ensure the diff length is 0.1 - var diff = slider.CurvePositionAt(completionProgress) - slider.CurvePositionAt(Math.Min(1, completionProgress + 0.1 / slider.Path.Distance)); + // 0.1 / slider.Path.Distance is the additional progress needed to ensure the diff length is 0.1 + double checkDistance = 0.1 / slider.Path.Distance; + var diff = slider.CurvePositionAt(Math.Min(1 - checkDistance, completionProgress)) - slider.CurvePositionAt(Math.Min(1, completionProgress + checkDistance)); // Ensure the value is substantially high enough to allow for Atan2 to get a valid angle. // Needed for when near completion, or in case of a very short slider. diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index 76b9fdc3ce..55e985c568 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -16,17 +16,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; - public override bool DisplayResult - { - get - { - if (HitObject?.ClassicSliderBehaviour == true) - return false; - - return base.DisplayResult; - } - } - private readonly IBindable pathVersion = new Bindable(); protected override OsuSkinComponents CirclePieceComponent => OsuSkinComponents.SliderHeadHitCircle; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 27c5278614..8205483f82 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -26,8 +27,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; - private double animDuration; - public SkinnableDrawable CirclePiece { get; private set; } public SkinnableDrawable Arrow { get; private set; } @@ -86,21 +85,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void UpdateInitialTransforms() { - // When snaking in is enabled, the first end circle needs to be delayed until the snaking completes. - bool delayFadeIn = DrawableSlider.SliderBody?.SnakingIn.Value == true && HitObject.RepeatIndex == 0; + base.UpdateInitialTransforms(); - animDuration = Math.Min(300, HitObject.SpanDuration); - - this - .FadeOut() - .Delay(delayFadeIn ? (Slider?.TimePreempt ?? 0) / 3 : 0) - .FadeIn(HitObject.RepeatIndex == 0 ? HitObject.TimeFadeIn : animDuration); + ApplyRepeatFadeIn(CirclePiece, HitObject.TimeFadeIn); + ApplyRepeatFadeIn(Arrow, 150); } protected override void UpdateHitStateTransforms(ArmedState state) { base.UpdateHitStateTransforms(state); + double animDuration = Math.Min(300, HitObject.SpanDuration); + switch (state) { case ArmedState.Idle: @@ -163,5 +159,36 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Arrow.Rotation = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 100), Arrow.Rotation, aimRotation, 0, 50, Easing.OutQuint); } } + + #region FOR EDITOR USE ONLY, DO NOT USE FOR ANY OTHER PURPOSE + + internal void SuppressHitAnimations() + { + UpdateState(ArmedState.Idle); + UpdateComboColour(); + + // This method is called every frame in editor contexts, thus the lack of need for transforms. + + bool hit = Time.Current >= HitStateUpdateTime; + + if (hit) + { + // More or less matches stable (see https://github.com/peppy/osu-stable-reference/blob/bb57924c1552adbed11ee3d96cdcde47cf96f2b6/osu!/GameplayElements/HitObjects/Osu/HitCircleOsu.cs#L336-L338) + AccentColour.Value = Color4.White; + Alpha = Interpolation.ValueAt(Time.Current, 1f, 0f, HitStateUpdateTime, HitStateUpdateTime + 700); + Arrow.Alpha = 0; + } + + LifetimeEnd = HitStateUpdateTime + 700; + } + + internal void RestoreHitAnimations() + { + UpdateState(ArmedState.Hit); + UpdateComboColour(); + Arrow.Alpha = 1; + } + + #endregion } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 8bb1b0aebc..e9f6f105bb 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -86,13 +86,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateInitialTransforms(); - // When snaking in is enabled, the first end circle needs to be delayed until the snaking completes. - bool delayFadeIn = DrawableSlider.SliderBody?.SnakingIn.Value == true && HitObject.RepeatIndex == 0; - - CirclePiece - .FadeOut() - .Delay(delayFadeIn ? (Slider?.TimePreempt ?? 0) / 3 : 0) - .FadeIn(HitObject.TimeFadeIn); + ApplyRepeatFadeIn(CirclePiece, HitObject.TimeFadeIn); } protected override void UpdateHitStateTransforms(ArmedState state) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 8c21e6a6bc..64cedd216b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -277,13 +277,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.Update(); if (HandleUserInput) - { - bool isValidSpinningTime = Time.Current >= HitObject.StartTime && Time.Current <= HitObject.EndTime; - - RotationTracker.Tracking = !Result.HasResult - && correctButtonPressed() - && isValidSpinningTime; - } + RotationTracker.Tracking = RotationTracker.IsSpinnableTime && !Result.HasResult && correctButtonPressed(); if (spinningSample != null && spinnerFrequencyModulate) spinningSample.Frequency.Value = spinning_sample_modulated_base_frequency + Progress; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs index 3776201626..7bb54487c0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs @@ -3,6 +3,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osuTK.Graphics; @@ -42,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (targetJudgement == null || targetResult == null) Colour = Color4.White; else - Colour = targetResult.IsHit ? targetJudgement.AccentColour : Color4.Transparent; + Colour = targetResult.IsHit && !targetResult.Type.IsTick() ? targetJudgement.AccentColour : Color4.Transparent; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 1b0993b698..01309c68f6 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -46,6 +46,8 @@ namespace osu.Game.Rulesets.Osu.Objects /// public const double PREEMPT_MAX = 1800; + public static readonly DifficultyRange PREEMPT_RANGE = new DifficultyRange(PREEMPT_MAX, PREEMPT_MID, PREEMPT_MIN); + public double TimePreempt { get; set; } = 600; public double TimeFadeIn = 400; @@ -59,8 +61,17 @@ namespace osu.Game.Rulesets.Osu.Objects set => position.Value = value; } - public float X => Position.X; - public float Y => Position.Y; + public float X + { + get => Position.X; + set => Position = new Vector2(value, Position.Y); + } + + public float Y + { + get => Position.Y; + set => Position = new Vector2(Position.X, value); + } public Vector2 StackedPosition => Position + StackOffset; @@ -160,7 +171,7 @@ namespace osu.Game.Rulesets.Osu.Objects { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_MAX, PREEMPT_MID, PREEMPT_MIN); + TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_RANGE); // 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. @@ -175,27 +186,26 @@ namespace osu.Game.Rulesets.Osu.Objects { // Note that this implementation is shared with the osu!catch ruleset's implementation. // If a change is made here, CatchHitObject.cs should also be updated. - ComboIndex = lastObj?.ComboIndex ?? 0; - ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + int index = lastObj?.ComboIndex ?? 0; + int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; - if (this is Spinner) + // - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. + // - At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo, + // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. + if (this is not Spinner && (NewCombo || lastObj == null || lastObj is Spinner)) { - // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. - return; - } - - // At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo, - // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. - if (NewCombo || lastObj == null || lastObj is Spinner) - { - IndexInCurrentCombo = 0; - ComboIndex++; - ComboIndexWithOffsets += ComboOffset + 1; + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; if (lastObj != null) lastObj.LastInCombo = true; } + + ComboIndex = index; + ComboIndexWithOffsets = indexWithOffsets; + IndexInCurrentCombo = inCurrentCombo; } protected override HitWindows CreateHitWindows() => new OsuHitWindows(); diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index e484efb408..94e98fbef7 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -68,24 +68,6 @@ namespace osu.Game.Rulesets.Osu.Objects } } - /// - /// The position of the cursor at the point of completion of this if it was hit - /// with as few movements as possible. This is set and used by difficulty calculation. - /// - internal Vector2? LazyEndPosition; - - /// - /// The distance travelled by the cursor upon completion of this if it was hit - /// with as few movements as possible. This is set and used by difficulty calculation. - /// - internal float LazyTravelDistance; - - /// - /// The time taken by the cursor upon completion of this if it was hit - /// with as few movements as possible. This is set and used by difficulty calculation. - /// - internal double LazyTravelTime; - public IList> NodeSamples { get; set; } = new List>(); [JsonIgnore] diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index e3dfe8e69a..6f6b848b38 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -21,12 +21,12 @@ namespace osu.Game.Rulesets.Osu.Objects /// /// The RPM required to clear the spinner at ODs [ 0, 5, 10 ]. /// - private static readonly (int min, int mid, int max) clear_rpm_range = (90, 150, 225); + public static readonly DifficultyRange CLEAR_RPM_RANGE = new DifficultyRange(90, 150, 225); /// /// The RPM required to complete the spinner and receive full score at ODs [ 0, 5, 10 ]. /// - private static readonly (int min, int mid, int max) complete_rpm_range = (250, 380, 430); + public static readonly DifficultyRange COMPLETE_RPM_RANGE = new DifficultyRange(250, 380, 430); public double EndTime { @@ -63,10 +63,10 @@ namespace osu.Game.Rulesets.Osu.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); // The average RPS required over the length of the spinner to clear the spinner. - double minRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, clear_rpm_range) / 60; + double minRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, CLEAR_RPM_RANGE) / 60; // The RPS required over the length of the spinner to receive full score (all normal + bonus ticks). - double maxRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, complete_rpm_range) / 60; + double maxRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, COMPLETE_RPM_RANGE) / 60; double secondsDuration = Duration / 1000; diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 25b1dd9b12..49d945e0aa 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -13,11 +15,13 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Difficulty; @@ -40,6 +44,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; +using osu.Game.Utils; using osuTK; namespace osu.Game.Rulesets.Osu @@ -365,22 +370,83 @@ namespace osu.Game.Rulesets.Osu /// /// - public override BeatmapDifficulty GetRateAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, double rate) + public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo difficulty, IReadOnlyCollection mods) { - BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty); + BeatmapDifficulty adjustedDifficulty = base.GetAdjustedDisplayDifficulty(difficulty, mods); + double rate = ModUtils.CalculateRateWithMods(mods); double preempt = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.ApproachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); preempt /= rate; adjustedDifficulty.ApproachRate = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); - var greatHitWindowRange = OsuHitWindows.OSU_RANGES.Single(range => range.Result == HitResult.Great); - double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, OsuHitWindows.GREAT_WINDOW_RANGE); greatHitWindow /= rate; - adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, OsuHitWindows.GREAT_WINDOW_RANGE); return adjustedDifficulty; } + public override IEnumerable GetBeatmapAttributesForDisplay(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) + { + var originalDifficulty = beatmapInfo.Difficulty; + // `modAdjustedDifficulty` contains only the direct effect of mods. + // `effectiveDifficulty` contains the "perceived" effect of rate-adjusting mods on OD and AR. + // we make a distinction here, because some of the calculations below will require very careful maneuvering between the two for correct results. + var modAdjustedDifficulty = base.GetAdjustedDisplayDifficulty(beatmapInfo, mods); + var effectiveDifficulty = GetAdjustedDisplayDifficulty(beatmapInfo, mods); + var colours = new OsuColour(); + + // for circle size, we can use `effectiveDifficulty` directly + yield return new RulesetBeatmapAttribute(SongSelectStrings.CircleSize, @"CS", originalDifficulty.CircleSize, effectiveDifficulty.CircleSize, 10) + { + Description = "Affects the size of hit circles and sliders.", + AdditionalMetrics = + [ + new RulesetBeatmapAttribute.AdditionalMetric("Hit circle radius", (OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(effectiveDifficulty.CircleSize, applyFudge: true)).ToLocalisableString("0.#")) + ] + }; + + // for approach rate, we can use `effectiveDifficulty` directly, and it is even convenient to do so (it correctly handles rate-changing mods like DT/HT) + yield return new RulesetBeatmapAttribute(SongSelectStrings.ApproachRate, @"AR", originalDifficulty.ApproachRate, effectiveDifficulty.ApproachRate, 10) + { + Description = "Affects how early objects appear on screen relative to their hit time.", + AdditionalMetrics = + [ + new RulesetBeatmapAttribute.AdditionalMetric("Approach time", LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRange(effectiveDifficulty.ApproachRate, OsuHitObject.PREEMPT_RANGE):#,0.##} ms")) + ] + }; + + // for OD is where it gets difficult. + // when displaying hit window ranges with rate-changing mods active, we will want to adjust for rate ourselves, as `effectiveDifficulty` may not be accurate + // because `OsuHitWindows` applies a floor-and-round operation that will result in inaccurate results + // (the floor-and-round needs to happen *before* rate is taken into account, not after). + // for spinner RPM requirements, we do not want to involve rate-changing mods *at all*, + // because rate-adjusting mods do not change the spin requirement (see `SpinnerRotationTracker.AddRotation()`). + var hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(modAdjustedDifficulty.OverallDifficulty); + double rate = ModUtils.CalculateRateWithMods(mods); + yield return new RulesetBeatmapAttribute(SongSelectStrings.Accuracy, @"OD", originalDifficulty.OverallDifficulty, effectiveDifficulty.OverallDifficulty, 10) + { + Description = "Affects timing requirements for hit circles and spin speed requirements for spinners.", + AdditionalMetrics = hitWindows.GetAllAvailableWindows() + .Reverse() + .Select(window => new RulesetBeatmapAttribute.AdditionalMetric( + $"{window.result.GetDescription().ToUpperInvariant()} hit window", + LocalisableString.Interpolate($@"±{hitWindows.WindowFor(window.result) / rate:0.##} ms"), + colours.ForHitResult(window.result) + )).Concat([ + new RulesetBeatmapAttribute.AdditionalMetric("RPM required to clear spinners", LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRange(modAdjustedDifficulty.OverallDifficulty, Spinner.CLEAR_RPM_RANGE):N0} RPM")), + new RulesetBeatmapAttribute.AdditionalMetric("RPM required to get full spinner bonus", LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRange(modAdjustedDifficulty.OverallDifficulty, Spinner.COMPLETE_RPM_RANGE):N0} RPM")), + ]).ToArray() + }; + + // HP drain is thankfully simple enough. + yield return new RulesetBeatmapAttribute(SongSelectStrings.HPDrain, @"HP", originalDifficulty.DrainRate, effectiveDifficulty.DrainRate, 10) + { + Description = "Affects the harshness of health drain and the health penalties for missing." + }; + } + public override bool EditorShowScrollSpeed => false; } } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs index 8082c5aef4..db2d9eaeda 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; @@ -47,5 +48,8 @@ namespace osu.Game.Rulesets.Osu.Replays return new LegacyReplayFrame(Time, Position.X, Position.Y, state); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is OsuReplayFrame osuFrame && Time == osuFrame.Time && Position == osuFrame.Position && Actions.SequenceEqual(osuFrame.Actions); } } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs index fd86e0eeda..a0f235c8c7 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs @@ -1,24 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { public class OsuHitWindows : HitWindows { + public static readonly DifficultyRange GREAT_WINDOW_RANGE = new DifficultyRange(80, 50, 20); + public static readonly DifficultyRange OK_WINDOW_RANGE = new DifficultyRange(140, 100, 60); + public static readonly DifficultyRange MEH_WINDOW_RANGE = new DifficultyRange(200, 150, 100); + /// /// osu! ruleset has a fixed miss window regardless of difficulty settings. /// public const double MISS_WINDOW = 400; - internal static readonly DifficultyRange[] OSU_RANGES = - { - new DifficultyRange(HitResult.Great, 80, 50, 20), - new DifficultyRange(HitResult.Ok, 140, 100, 60), - new DifficultyRange(HitResult.Meh, 200, 150, 100), - new DifficultyRange(HitResult.Miss, MISS_WINDOW, MISS_WINDOW, MISS_WINDOW), - }; + private double great; + private double ok; + private double meh; public override bool IsHitResultAllowed(HitResult result) { @@ -34,6 +36,32 @@ namespace osu.Game.Rulesets.Osu.Scoring return false; } - protected override DifficultyRange[] GetRanges() => OSU_RANGES; + public override void SetDifficulty(double difficulty) + { + great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE)) - 0.5; + ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE)) - 0.5; + meh = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, MEH_WINDOW_RANGE)) - 0.5; + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Great: + return great; + + case HitResult.Ok: + return ok; + + case HitResult.Meh: + return meh; + + case HitResult.Miss: + return MISS_WINDOW; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs index 87b89a07cf..bb5499b1a5 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs @@ -5,12 +5,12 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -75,44 +75,41 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon accentColour = drawableRepeat.AccentColour.GetBoundCopy(); accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true); - - drawableRepeat.ApplyCustomUpdateState += updateStateTransforms; } - private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + protected override void Update() { + base.Update(); + + if (Time.Current >= drawableRepeat.HitStateUpdateTime && drawableRepeat.State.Value == ArmedState.Hit) + { + double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration); + Scale = new Vector2(Interpolation.ValueAt(Time.Current, 1, 1.5f, drawableRepeat.HitStateUpdateTime, drawableRepeat.HitStateUpdateTime + animDuration, Easing.Out)); + + // When hit, don't animate further. This avoids a scale being applied on a scale and looking very weird. + return; + } + + Scale = Vector2.One; + const float move_distance = -12; + const float scale_amount = 1.3f; + const double move_out_duration = 35; const double move_in_duration = 250; const double total = 300; - switch (state) - { - case ArmedState.Idle: - main.ScaleTo(1.3f, move_out_duration, Easing.Out) - .Then() - .ScaleTo(1f, move_in_duration, Easing.Out) - .Loop(total - (move_in_duration + move_out_duration)); - side - .MoveToX(move_distance, move_out_duration, Easing.Out) - .Then() - .MoveToX(0, move_in_duration, Easing.Out) - .Loop(total - (move_in_duration + move_out_duration)); - break; + double loopCurrentTime = (Time.Current - drawableRepeat.AnimationStartTime.Value) % total; - case ArmedState.Hit: - double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration); - this.ScaleTo(1.5f, animDuration, Easing.Out); - break; - } - } + if (loopCurrentTime < move_out_duration) + main.Scale = new Vector2(Interpolation.ValueAt(loopCurrentTime, 1, scale_amount, 0, move_out_duration, Easing.Out)); + else + main.Scale = new Vector2(Interpolation.ValueAt(loopCurrentTime, scale_amount, 1f, move_out_duration, move_out_duration + move_in_duration, Easing.Out)); - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (drawableRepeat.IsNotNull()) - drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms; + if (loopCurrentTime < move_out_duration) + side.X = Interpolation.ValueAt(loopCurrentTime, 0, move_distance, 0, move_out_duration, Easing.Out); + else + side.X = Interpolation.ValueAt(loopCurrentTime, move_distance, 0, move_out_duration, move_out_duration + move_in_duration, Easing.Out); } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs index c3d08116ac..abb414c82c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs @@ -3,12 +3,16 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Argon { public partial class ArgonSliderBody : PlaySliderBody { + // Eventually this would be a user setting. + public float BodyAlpha { get; init; } = 1; + protected override void LoadComplete() { const float path_radius = ArgonMainCirclePiece.OUTER_GRADIENT_SIZE / 2; @@ -26,6 +30,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon protected override Default.DrawableSliderPath CreateSliderPath() => new DrawableSliderPath(); + protected override Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) + { + return base.GetBodyAccentColour(skin, hitObjectAccentColour).Opacity(BodyAlpha); + } + private partial class DrawableSliderPath : Default.DrawableSliderPath { protected override Color4 ColourAt(float position) diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs index 9f6f65c206..ecc0f3fd0a 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs @@ -16,17 +16,23 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { + bool isPro = Skin is ArgonProSkin; + switch (lookup) { case SkinComponentLookup resultComponent: HitResult result = resultComponent.Component; // This should eventually be moved to a skin setting, when supported. - if (Skin is ArgonProSkin && (result == HitResult.Great || result == HitResult.Perfect)) + if (isPro && (result == HitResult.Great || result == HitResult.Perfect)) return Drawable.Empty(); switch (result) { + case HitResult.LargeTickHit: + case HitResult.SliderTailHit: + return null; + case HitResult.IgnoreMiss: case HitResult.LargeTickMiss: return new ArgonJudgementPieceSliderTickMiss(result); @@ -46,7 +52,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon return new ArgonMainCirclePiece(false); case OsuSkinComponents.SliderBody: - return new ArgonSliderBody(); + return new ArgonSliderBody + { + BodyAlpha = isPro ? 0.92f : 0.98f + }; case OsuSkinComponents.SliderBall: return new ArgonSliderBall(); diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs index ad49150d81..5e2d04700d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs @@ -3,10 +3,10 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -40,37 +40,31 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private void load(DrawableHitObject drawableObject) { drawableRepeat = (DrawableSliderRepeat)drawableObject; - drawableRepeat.ApplyCustomUpdateState += updateStateTransforms; } - private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + protected override void Update() { - const double move_out_duration = 35; - const double move_in_duration = 250; - const double total = 300; + base.Update(); - switch (state) + if (Time.Current >= drawableRepeat.HitStateUpdateTime && drawableRepeat.State.Value == ArmedState.Hit) { - case ArmedState.Idle: - InternalChild.ScaleTo(1.3f, move_out_duration, Easing.Out) - .Then() - .ScaleTo(1f, move_in_duration, Easing.Out) - .Loop(total - (move_in_duration + move_out_duration)); - break; - - case ArmedState.Hit: - double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration); - InternalChild.ScaleTo(1.5f, animDuration, Easing.Out); - break; + double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration); + Scale = new Vector2(Interpolation.ValueAt(Time.Current, 1, 1.5f, drawableRepeat.HitStateUpdateTime, drawableRepeat.HitStateUpdateTime + animDuration, Easing.Out)); } - } + else + { + const float scale_amount = 1.3f; - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); + const double move_out_duration = 35; + const double move_in_duration = 250; + const double total = 300; - if (drawableRepeat.IsNotNull()) - drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms; + double loopCurrentTime = (Time.Current - drawableRepeat.AnimationStartTime.Value) % total; + if (loopCurrentTime < move_out_duration) + Scale = new Vector2(Interpolation.ValueAt(loopCurrentTime, 1, scale_amount, 0, move_out_duration, Easing.Out)); + else + Scale = new Vector2(Interpolation.ValueAt(loopCurrentTime, scale_amount, 1f, move_out_duration, move_out_duration + move_in_duration, Easing.Out)); + } } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs index bda1e6cf41..7b43886057 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs @@ -44,10 +44,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default SnakingOut.BindTo(configSnakingOut); - BorderColour = skin.GetConfig(OsuSkinColour.SliderBorder)?.Value ?? Color4.White; + BorderColour = GetBorderColour(skin); } - protected virtual Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) => - skin.GetConfig(OsuSkinColour.SliderTrackOverride)?.Value ?? hitObjectAccentColour; + protected virtual Color4 GetBorderColour(ISkinSource skin) => Color4.White; + + protected virtual Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) => hitObjectAccentColour; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs index 7e97f826f9..7cd1f39871 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// /// Whether currently in the correct time range to allow spinning. /// - private bool isSpinnableTime => drawableSpinner.HitObject.StartTime <= Time.Current && drawableSpinner.HitObject.EndTime > Time.Current; + public bool IsSpinnableTime => drawableSpinner.HitObject.StartTime <= Time.Current && drawableSpinner.HitObject.EndTime > Time.Current; protected override bool OnMouseMove(MouseMoveEvent e) { @@ -79,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default lastAngle = thisAngle; } - IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f; + IsSpinning.Value = IsSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f; Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed)); } @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// The delta angle. public void AddRotation(float delta) { - if (!isSpinnableTime) + if (!IsSpinnableTime) return; if (!rotationTransferred) diff --git a/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs index 4fadb09948..db789166c6 100644 --- a/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; @@ -13,8 +14,9 @@ namespace osu.Game.Rulesets.Osu.Skinning { public abstract partial class FollowCircle : CompositeDrawable { - [Resolved] - protected DrawableHitObject? ParentObject { get; private set; } + protected DrawableSlider? DrawableObject { get; private set; } + + private readonly IBindable tracking = new Bindable(); protected FollowCircle() { @@ -22,65 +24,73 @@ namespace osu.Game.Rulesets.Osu.Skinning } [BackgroundDependencyLoader] - private void load() + private void load(DrawableHitObject? hitObject) { - ((DrawableSlider?)ParentObject)?.Tracking.BindValueChanged(tracking => + DrawableObject = hitObject as DrawableSlider; + + if (DrawableObject != null) { - Debug.Assert(ParentObject != null); - - if (ParentObject.Judged) - return; - - using (BeginAbsoluteSequence(Math.Max(Time.Current, ParentObject.HitObject?.StartTime ?? 0))) + tracking.BindTo(DrawableObject.Tracking); + tracking.BindValueChanged(tracking => { - if (tracking.NewValue) - OnSliderPress(); - else - OnSliderRelease(); - } - }, true); + if (DrawableObject.Judged) + return; + + using (BeginAbsoluteSequence(Math.Max(Time.Current, DrawableObject.HitObject?.StartTime ?? 0))) + { + if (tracking.NewValue) + OnSliderPress(); + else + OnSliderRelease(); + } + }, true); + } } protected override void LoadComplete() { base.LoadComplete(); - if (ParentObject != null) + if (DrawableObject != null) { - ParentObject.HitObjectApplied += onHitObjectApplied; - onHitObjectApplied(ParentObject); + DrawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(DrawableObject); - ParentObject.ApplyCustomUpdateState += updateStateTransforms; - updateStateTransforms(ParentObject, ParentObject.State.Value); + DrawableObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(DrawableObject, DrawableObject.State.Value); } } private void onHitObjectApplied(DrawableHitObject drawableObject) { + // Sane defaults when a new hitobject is applied to the drawable slider. this.ScaleTo(1f) .FadeOut(); + + // Immediately play out any pending transforms from press/release + FinishTransforms(true); } - private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState state) + private void updateStateTransforms(DrawableHitObject d, ArmedState state) { - Debug.Assert(ParentObject != null); + Debug.Assert(DrawableObject != null); switch (state) { case ArmedState.Hit: - switch (drawableObject) + switch (d) { case DrawableSliderTail: - // Use ParentObject instead of drawableObject because slider tail's + // Use DrawableObject instead of local object because slider tail's // HitStateUpdateTime is ~36ms before the actual slider end (aka slider // tail leniency) - using (BeginAbsoluteSequence(ParentObject.HitStateUpdateTime)) + using (BeginAbsoluteSequence(DrawableObject.HitStateUpdateTime)) OnSliderEnd(); break; case DrawableSliderTick: case DrawableSliderRepeat: - using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + using (BeginAbsoluteSequence(d.HitStateUpdateTime)) OnSliderTick(); break; } @@ -88,15 +98,15 @@ namespace osu.Game.Rulesets.Osu.Skinning break; case ArmedState.Miss: - switch (drawableObject) + switch (d) { case DrawableSliderTail: case DrawableSliderTick: case DrawableSliderRepeat: - // Despite above comment, ok to use drawableObject.HitStateUpdateTime + // Despite above comment, ok to use d.HitStateUpdateTime // here, since on stable, the break anim plays right when the tail is // missed, not when the slider ends - using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + using (BeginAbsoluteSequence(d.HitStateUpdateTime)) OnSliderBreak(); break; } @@ -109,10 +119,10 @@ namespace osu.Game.Rulesets.Osu.Skinning { base.Dispose(isDisposing); - if (ParentObject != null) + if (DrawableObject != null) { - ParentObject.HitObjectApplied -= onHitObjectApplied; - ParentObject.ApplyCustomUpdateState -= updateStateTransforms; + DrawableObject.HitObjectApplied -= onHitObjectApplied; + DrawableObject.ApplyCustomUpdateState -= updateStateTransforms; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs index 375d81049d..e526c4f14c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs @@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public partial class LegacyCursor : SkinnableCursor { + public static readonly int REVOLUTION_DURATION = 10000; + private const float pressed_scale = 1.3f; private const float released_scale = 1f; @@ -52,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy protected override void LoadComplete() { if (spin) - ExpandTarget.Spin(10000, RotationDirection.Clockwise); + ExpandTarget.Spin(REVOLUTION_DURATION, RotationDirection.Clockwise); } public override void Expand() diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs index ca0002d8c0..375bef721d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs @@ -34,6 +34,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private void load(OsuConfigManager config, ISkinSource skinSource) { cursorSize = config.GetBindable(OsuSetting.GameplayCursorSize).GetBoundCopy(); + AllowPartRotation = skin.GetConfig(OsuSkinConfiguration.CursorTrailRotate)?.Value ?? true; Texture = skin.GetTexture("cursortrail"); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs index 4a8b737206..f60b5cfe12 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs @@ -22,9 +22,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy protected override void OnSliderPress() { - Debug.Assert(ParentObject != null); + Debug.Assert(DrawableObject != null); - double remainingTime = Math.Max(0, ParentObject.HitStateUpdateTime - Time.Current); + double remainingTime = Math.Max(0, DrawableObject.HitStateUpdateTime - Time.Current); // Note that the scale adjust here is 2 instead of DrawableSliderBall.FOLLOW_AREA to match legacy behaviour. // This means the actual tracking area for gameplay purposes is larger than the sprite (but skins may be accounting for this). diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyJudgementPieceSliderTickHit.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyJudgementPieceSliderTickHit.cs new file mode 100644 index 0000000000..8c89f4c9c8 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyJudgementPieceSliderTickHit.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.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public partial class LegacyJudgementPieceSliderTickHit : Sprite, IAnimatableJudgement + { + public void PlayAnimation() + { + // https://github.com/peppy/osu-stable-reference/blob/0e91e49bc83fe8b21c3ba5f1eb2d5d06456eae84/osu!/GameModes/Play/Rulesets/Ruleset.cs#L804-L806 + this.MoveToOffset(new Vector2(0, -10), 300, Easing.Out) + .Then() + .FadeOut(60); + } + + public Drawable GetAboveHitObjectsProxiedContent() => CreateProxy(); + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index 0dc0f065d4..e74ffaac0c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -61,13 +61,16 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy var drawableOsuObject = (DrawableOsuHitObject?)drawableObject; - // As a precondition, ensure that any prefix lookups are run against the skin which is providing "hitcircle". + // As a precondition, prefer that any *prefix* lookups are run against the skin which is providing "hitcircle". // This is to correctly handle a case such as: // // - Beatmap provides `hitcircle` // - User skin provides `sliderstartcircle` // // In such a case, the `hitcircle` should be used for slider start circles rather than the user's skin override. + // + // Of note, this consideration should only be used to decide whether to continue looking up the prefixed name or not. + // The final lookups must still run on the full skin hierarchy as per usual in order to correctly handle fallback cases. var provider = skin.FindProvider(s => s.GetTexture(base_lookup) != null) ?? skin; // if a base texture for the specified prefix exists, continue using it for subsequent lookups. @@ -81,7 +84,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png. InternalChildren = new[] { - CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = provider.GetTexture(circleName)?.WithMaximumSize(maxSize) }) + CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(maxSize) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -90,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = provider.GetTexture(@$"{circleName}overlay")?.WithMaximumSize(maxSize) }) + Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(@$"{circleName}overlay")?.WithMaximumSize(maxSize) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs index ad1fb98aef..85c895006b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs @@ -9,10 +9,12 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy @@ -51,8 +53,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy textureIsDefaultSkin = skin is ISkinTransformer transformer && transformer.Skin is DefaultLegacySkin; - drawableObject.ApplyCustomUpdateState += updateStateTransforms; - shouldRotate = skinSource.GetConfig(SkinConfiguration.LegacySetting.Version)?.Value <= 1; } @@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy accentColour = drawableRepeat.AccentColour.GetBoundCopy(); accentColour.BindValueChanged(c => { - arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White; + arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > 600 / 255f ? Color4.Black : Color4.White; }, true); } @@ -80,36 +80,32 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy drawableRepeat.DrawableSlider.OverlayElementContainer.Add(proxy); } - private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + protected override void Update() { - const double duration = 300; - const float rotation = 5.625f; + base.Update(); - switch (state) + if (Time.Current >= drawableRepeat.HitStateUpdateTime && drawableRepeat.State.Value == ArmedState.Hit) { - case ArmedState.Idle: - if (shouldRotate) - { - InternalChild.ScaleTo(1.3f) - .RotateTo(rotation) - .Then() - .ScaleTo(1f, duration) - .RotateTo(-rotation, duration) - .Loop(); - } - else - { - InternalChild.ScaleTo(1.3f).Then() - .ScaleTo(1f, duration, Easing.Out) - .Loop(); - } + double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration); + arrow.Scale = new Vector2(Interpolation.ValueAt(Time.Current, 1, 1.4f, drawableRepeat.HitStateUpdateTime, drawableRepeat.HitStateUpdateTime + animDuration, Easing.Out)); + } + else + { + const double duration = 300; + const float rotation = 5.625f; - break; + double loopCurrentTime = (Time.Current - drawableRepeat.AnimationStartTime.Value) % duration; - case ArmedState.Hit: - double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration); - InternalChild.ScaleTo(1.4f, animDuration, Easing.Out); - break; + // Reference: https://github.com/peppy/osu-stable-reference/blob/2280c4c436f80d04f9c79d3c905db00ac2902273/osu!/GameplayElements/HitObjects/Osu/HitCircleSliderEnd.cs#L79-L96 + if (shouldRotate) + { + arrow.Rotation = Interpolation.ValueAt(loopCurrentTime, rotation, -rotation, 0, duration); + arrow.Scale = new Vector2(Interpolation.ValueAt(loopCurrentTime, 1.3f, 1, 0, duration)); + } + else + { + arrow.Scale = new Vector2(Interpolation.ValueAt(loopCurrentTime, 1.3f, 1, 0, duration, Easing.Out)); + } } } @@ -120,7 +116,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (drawableRepeat.IsNotNull()) { drawableRepeat.HitObjectApplied -= onHitObjectApplied; - drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms; } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs index b54bb44f94..43b7260e2c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs @@ -15,11 +15,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { protected override DrawableSliderPath CreateSliderPath() => new LegacyDrawableSliderPath(); + protected override Color4 GetBorderColour(ISkinSource skin) + => skin.GetConfig(OsuSkinColour.SliderBorder)?.Value ?? Color4.White; + 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); - } + => (skin.GetConfig(OsuSkinColour.SliderTrackOverride)?.Value ?? hitObjectAccentColour).Opacity(0.7f); private partial class LegacyDrawableSliderPath : DrawableSliderPath { diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index 5a95eac0f1..569e01ae56 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -62,24 +62,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - spin = new Sprite - { - Alpha = 0, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-spin"), - Scale = new Vector2(SPRITE_SCALE), - Y = SPINNER_TOP_OFFSET + 335, - }, - clear = new Sprite - { - Alpha = 0, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-clear"), - Scale = new Vector2(SPRITE_SCALE), - Y = SPINNER_TOP_OFFSET + 115, - }, bonusCounter = new LegacySpriteText(LegacyFont.Score) { Alpha = 0, @@ -103,6 +85,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Scale = new Vector2(SPRITE_SCALE * 0.9f), Position = new Vector2(80, 448 + spm_hide_offset), }, + spin = new Sprite + { + Alpha = 0, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-spin"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_TOP_OFFSET + 335, + }, + clear = new Sprite + { + Alpha = 0, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-clear"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_TOP_OFFSET + 115, + }, } }); } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 636a9ecb21..7118b6f95e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -5,7 +5,10 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osuTK; @@ -70,12 +73,35 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } var combo = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + var leaderboard = container.OfType().FirstOrDefault(); + + Vector2 pos = new Vector2(); if (combo != null) { combo.Anchor = Anchor.BottomLeft; combo.Origin = Anchor.BottomLeft; combo.Scale = new Vector2(1.28f); + + pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X); + } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = pos; + + // maximum height of the spectator list is around ~172 units + pos += new Vector2(0, -185); + } + + if (leaderboard != null) + { + leaderboard.Anchor = Anchor.BottomLeft; + leaderboard.Origin = Anchor.BottomLeft; + leaderboard.Position = pos; } }) { @@ -83,12 +109,47 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { new LegacyDefaultComboCounter(), new LegacyKeyCounterDisplay(), + new SpectatorList(), + new DrawableGameplayLeaderboard(), } }; } return null; + case SkinComponentLookup resultComponent: + switch (resultComponent.Component) + { + case HitResult.LargeTickHit: + case HitResult.SliderTailHit: + if (getSliderPointTexture(resultComponent.Component) is Texture texture) + return new LegacyJudgementPieceSliderTickHit { Texture = texture }; + + break; + + // If the corresponding hit result displays a judgement and the miss texture isn't provided by this skin, don't look up the miss texture from any further skins. + case HitResult.LargeTickMiss: + case HitResult.IgnoreMiss: + if (getSliderPointTexture(resultComponent.Component == HitResult.LargeTickMiss + ? HitResult.LargeTickHit + : HitResult.SliderTailHit) != null) + return base.GetDrawableComponent(lookup) ?? Drawable.Empty(); + + break; + } + + return base.GetDrawableComponent(lookup); + + Texture? getSliderPointTexture(HitResult result) + { + // https://github.com/peppy/osu-stable-reference/blob/0e91e49bc83fe8b21c3ba5f1eb2d5d06456eae84/osu!/GameModes/Play/Rulesets/Ruleset.cs#L799 + if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2m) + // Note that osu!stable used sliderpoint30 for heads and repeats, and sliderpoint10 for ticks, but the mapping is intentionally changed here so that each texture represents one type of HitResult. + return GetTexture(result == HitResult.LargeTickHit ? "sliderpoint30" : "sliderpoint10"); + + return null; + } + case OsuSkinComponentLookup osuComponent: switch (osuComponent.Component) { diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 9685ab685d..81488ca1a3 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -9,6 +9,7 @@ namespace osu.Game.Rulesets.Osu.Skinning CursorCentre, CursorExpand, CursorRotate, + CursorTrailRotate, HitCircleOverlayAboveNumber, // ReSharper disable once IdentifierTypo diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs index f4fe42b8de..2962bce635 100644 --- a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -77,9 +77,14 @@ namespace osu.Game.Rulesets.Osu.Skinning base.LoadComplete(); RelativeSizeAxes = Axes.Both; + } - LifetimeStart = smokeStartTime = Time.Current; - + public void StartDrawing(double time) + { + LifetimeStart = smokeStartTime = time; + LifetimeEnd = smokeEndTime = double.MaxValue; + SmokePoints.Clear(); + lastPosition = null; totalDistance = pointInterval; } diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs index 41620bc3d8..4a028d677a 100644 --- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -232,10 +232,47 @@ namespace osu.Game.Rulesets.Osu.Statistics if (pointGrid.Content.Count == 0) return; - double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point. - double angle2 = Math.Atan2(end.Y - start.Y, start.X - end.X); // Angle between the end point and the start point. + Vector2 relativePosition = FindRelativeHitPosition(start, end, hitPoint, radius, rotation); + + var localCentre = new Vector2(points_per_dimension - 1) / 2; + float localRadius = localCentre.X * inner_portion; + var localPoint = localCentre + localRadius * relativePosition; + + // Find the most relevant hit point. + int r = (int)Math.Round(localPoint.Y); + int c = (int)Math.Round(localPoint.X); + + if (r < 0 || r >= points_per_dimension || c < 0 || c >= points_per_dimension) + return; + + PeakValue = Math.Max(PeakValue, ((GridPoint)pointGrid.Content[r][c]).Increment()); + + bufferedGrid.ForceRedraw(); + } + + /// + /// Normalises the position of a hit on a circle such that it is relative to the movement that was performed to arrive at said circle. + /// + /// The position of the object prior to the one getting hit. + /// The position of the object which is getting hit. + /// The point at which the user hit. + /// The radius of and . + /// + /// The rotation of the axis which is to be considered in the same direction as the vector + /// leading from to . + /// + /// + /// A 2D vector representing the as relative to the movement between and + /// and relative to the . + /// If the object was hit perfectly in the middle, the return value will be . + /// If the object was hit perfectly at its edge, the returned vector will have a magnitude of 1. + /// + public static Vector2 FindRelativeHitPosition(Vector2 previousObjectPosition, Vector2 nextObjectPosition, Vector2 hitPoint, float objectRadius, float rotation) + { + double angle1 = Math.Atan2(nextObjectPosition.Y - hitPoint.Y, hitPoint.X - nextObjectPosition.X); // Angle between the end point and the hit point. + double angle2 = Math.Atan2(nextObjectPosition.Y - previousObjectPosition.Y, previousObjectPosition.X - nextObjectPosition.X); // Angle between the end point and the start point. double finalAngle = angle2 - angle1; // Angle between start, end, and hit points. - float normalisedDistance = Vector2.Distance(hitPoint, end) / radius; + float normalisedDistance = Vector2.Distance(hitPoint, nextObjectPosition) / objectRadius; // Distance between the hit point and the end point. // Consider two objects placed horizontally, with the start on the left and the end on the right. // The above calculated the angle between {end, start}, and the angle between {end, hitPoint}, in the form: @@ -254,22 +291,7 @@ namespace osu.Game.Rulesets.Osu.Statistics // // We also need to apply the anti-clockwise rotation. double rotatedAngle = finalAngle - float.DegreesToRadians(rotation); - var rotatedCoordinate = -1 * new Vector2((float)Math.Cos(rotatedAngle), (float)Math.Sin(rotatedAngle)); - - Vector2 localCentre = new Vector2(points_per_dimension - 1) / 2; - float localRadius = localCentre.X * inner_portion * normalisedDistance; - Vector2 localPoint = localCentre + localRadius * rotatedCoordinate; - - // Find the most relevant hit point. - int r = (int)Math.Round(localPoint.Y); - int c = (int)Math.Round(localPoint.X); - - if (r < 0 || r >= points_per_dimension || c < 0 || c >= points_per_dimension) - return; - - PeakValue = Math.Max(PeakValue, ((GridPoint)pointGrid.Content[r][c]).Increment()); - - bufferedGrid.ForceRedraw(); + return -normalisedDistance * new Vector2((float)Math.Cos(rotatedAngle), (float)Math.Sin(rotatedAngle)); } private abstract partial class GridPoint : CompositeDrawable diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 5132dc2859..1c2d69fa00 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -34,19 +34,24 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor /// protected virtual float FadeExponent => 1.7f; - private readonly TrailPart[] parts = new TrailPart[max_sprites]; - private int currentIndex; - private IShader shader; - private double timeOffset; - private float time; - /// /// The scale used on creation of a new trail part. /// - public Vector2 NewPartScale = Vector2.One; + public Vector2 NewPartScale { get; set; } = Vector2.One; - private Anchor trailOrigin = Anchor.Centre; + /// + /// The rotation (in degrees) to apply to trail parts when is true. + /// + public float PartRotation { get; set; } + /// + /// Whether to rotate trail parts based on the value of . + /// + protected bool AllowPartRotation { get; set; } + + /// + /// The trail part texture origin. + /// protected Anchor TrailOrigin { get => trailOrigin; @@ -57,6 +62,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } } + private readonly TrailPart[] parts = new TrailPart[max_sprites]; + private Anchor trailOrigin = Anchor.Centre; + private int currentIndex; + private IShader shader; + private double timeOffset; + private float time; + public CursorTrail() { // as we are currently very dependent on having a running clock, let's make our own clock for the time being. @@ -220,6 +232,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private float time; private float fadeExponent; + private float angle; private readonly TrailPart[] parts = new TrailPart[max_sprites]; private Vector2 originPosition; @@ -239,6 +252,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor texture = Source.texture; time = Source.time; fadeExponent = Source.FadeExponent; + angle = Source.AllowPartRotation ? float.DegreesToRadians(Source.PartRotation) : 0; originPosition = Vector2.Zero; @@ -279,6 +293,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor renderer.PushLocalMatrix(DrawInfo.Matrix); + float sin = MathF.Sin(angle); + float cos = MathF.Cos(angle); + foreach (var part in parts) { if (part.InvalidationID == -1) @@ -289,7 +306,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), + Position = rotateAround( + new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), + part.Position, sin, cos), TexturePosition = textureRect.BottomLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomLeft.Linear, @@ -298,7 +317,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), + Position = rotateAround( + new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, + part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos), TexturePosition = textureRect.BottomRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomRight.Linear, @@ -307,7 +328,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + Position = rotateAround( + new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + part.Position, sin, cos), TexturePosition = textureRect.TopRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopRight.Linear, @@ -316,7 +339,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + Position = rotateAround( + new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + part.Position, sin, cos), TexturePosition = textureRect.TopLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopLeft.Linear, @@ -330,6 +355,14 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader.Unbind(); } + private static Vector2 rotateAround(Vector2 input, Vector2 origin, float sin, float cos) + { + float xTranslated = input.X - origin.X; + float yTranslated = input.Y - origin.Y; + + return new Vector2(xTranslated * cos - yTranslated * sin, xTranslated * sin + yTranslated * cos) + origin; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index c2f7d84f5e..e84fb9e2d6 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -36,6 +36,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor /// public Vector2 CurrentExpandedScale => skinnableCursor.ExpandTarget?.Scale ?? Vector2.One; + /// + /// The current rotation of the cursor. + /// + public float CurrentRotation => skinnableCursor.ExpandTarget?.Rotation ?? 0; + public IBindable CursorScale => cursorScale; /// diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 8c0871d54f..974d99d7c8 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -83,7 +83,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor base.Update(); if (cursorTrail.Drawable is CursorTrail trail) + { trail.NewPartScale = ActiveCursor.CurrentExpandedScale; + trail.PartRotation = ActiveCursor.CurrentRotation; + } } public bool OnPressed(KeyBindingPressEvent e) diff --git a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs index ab69b67051..12d5363469 100644 --- a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs @@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.Osu.UI protected override ResumeOverlay CreateResumeOverlay() { - if (Mods.Any(m => m is OsuModAutopilot)) + if (Mods.Any(m => m is OsuModAutopilot or OsuModTouchDevice)) return new DelayedResumeOverlay { Scale = new Vector2(0.65f) }; return new OsuResumeOverlay(); diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 7d9f5eb1a8..e379c44314 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -81,6 +81,8 @@ namespace osu.Game.Rulesets.Osu.UI HitResult.Ok, HitResult.Meh, HitResult.Miss, + HitResult.LargeTickHit, + HitResult.SliderTailHit, HitResult.LargeTickMiss, HitResult.IgnoreMiss, }, onJudgementLoaded)); diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs index dc4730d76a..f05f3aa03a 100644 --- a/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Screens.Play.PlayerSettings; @@ -13,19 +14,19 @@ namespace osu.Game.Rulesets.Osu.UI { private readonly OsuRulesetConfigManager config; - [SettingSource("Show click markers", SettingControlType = typeof(PlayerCheckbox))] + [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowClickMarkers), SettingControlType = typeof(PlayerCheckbox))] public BindableBool ShowClickMarkers { get; } = new BindableBool(); - [SettingSource("Show frame markers", SettingControlType = typeof(PlayerCheckbox))] + [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowFrameMarkers), SettingControlType = typeof(PlayerCheckbox))] public BindableBool ShowAimMarkers { get; } = new BindableBool(); - [SettingSource("Show cursor path", SettingControlType = typeof(PlayerCheckbox))] + [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowCursorPath), SettingControlType = typeof(PlayerCheckbox))] public BindableBool ShowCursorPath { get; } = new BindableBool(); - [SettingSource("Hide gameplay cursor", SettingControlType = typeof(PlayerCheckbox))] + [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.HideGameplayCursor), SettingControlType = typeof(PlayerCheckbox))] public BindableBool HideSkinCursor { get; } = new BindableBool(); - [SettingSource("Display length", SettingControlType = typeof(PlayerSliderBar))] + [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.DisplayLength), SettingControlType = typeof(PlayerSliderBar))] public BindableInt DisplayLength { get; } = new BindableInt { MinValue = 200, @@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.UI }; public ReplayAnalysisSettings(OsuRulesetConfigManager config) - : base("Analysis Settings") + : base(PlayerSettingsOverlayStrings.AnalysisSettingsTitle) { this.config = config; } diff --git a/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs index 389440ba2d..ff28444e82 100644 --- a/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/SmokeContainer.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 osu.Framework.Graphics; +using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -19,17 +19,24 @@ namespace osu.Game.Rulesets.Osu.UI /// public partial class SmokeContainer : Container, IRequireHighFrequencyMousePosition, IKeyBindingHandler { + private DrawablePool segmentPool = null!; private SmokeSkinnableDrawable? currentSegmentSkinnable; private Vector2 lastMousePosition; public override bool ReceivePositionalInputAt(Vector2 _) => true; + [BackgroundDependencyLoader] + private void load() + { + AddInternal(segmentPool = new DrawablePool(10)); + } + public bool OnPressed(KeyBindingPressEvent e) { if (e.Action == OsuAction.Smoke) { - AddInternal(currentSegmentSkinnable = new SmokeSkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorSmoke), _ => new DefaultSmokeSegment())); + AddInternal(currentSegmentSkinnable = segmentPool.Get(segment => segment.Segment?.StartDrawing(Time.Current))); // Add initial position immediately. addPosition(); @@ -59,17 +66,19 @@ namespace osu.Game.Rulesets.Osu.UI return base.OnMouseMove(e); } - private void addPosition() => (currentSegmentSkinnable?.Drawable as SmokeSegment)?.AddPosition(lastMousePosition, Time.Current); + private void addPosition() => currentSegmentSkinnable?.Segment?.AddPosition(lastMousePosition, Time.Current); private partial class SmokeSkinnableDrawable : SkinnableDrawable { + public SmokeSegment? Segment => Drawable as SmokeSegment; + public override bool RemoveWhenNotAlive => true; public override double LifetimeStart => Drawable.LifetimeStart; public override double LifetimeEnd => Drawable.LifetimeEnd; - public SmokeSkinnableDrawable(ISkinComponentLookup lookup, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) - : base(lookup, defaultImplementation, confineMode) + public SmokeSkinnableDrawable() + : base(new OsuSkinComponentLookup(OsuSkinComponents.CursorSmoke), _ => new DefaultSmokeSegment()) { } } diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.Reposition.cs similarity index 100% rename from osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs rename to osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.Reposition.cs diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/AppDelegate.cs b/osu.Game.Rulesets.Taiko.Tests.iOS/AppDelegate.cs new file mode 100644 index 0000000000..4bfc12e7e8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/AppDelegate.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 Foundation; +using osu.Framework.iOS; +using osu.Game.Tests; + +namespace osu.Game.Rulesets.Taiko.Tests.iOS +{ + [Register("AppDelegate")] + public class AppDelegate : GameApplicationDelegate + { + protected override Framework.Game CreateGame() => new OsuTestBrowser(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs b/osu.Game.Rulesets.Taiko.Tests.iOS/Program.cs similarity index 66% rename from osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs rename to osu.Game.Rulesets.Taiko.Tests.iOS/Program.cs index 0b6a11d8c2..bf2ffecb23 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Program.cs @@ -1,16 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.iOS; -using osu.Game.Tests; +using UIKit; namespace osu.Game.Rulesets.Taiko.Tests.iOS { - public static class Application + public static class Program { public static void Main(string[] args) { - GameApplication.Main(new OsuTestBrowser()); + UIApplication.Main(args, null, typeof(AppDelegate)); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/Checks/CheckTaikoInconsistentSkipBarLineTest.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/Checks/CheckTaikoInconsistentSkipBarLineTest.cs new file mode 100644 index 0000000000..7ebbde0360 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/Checks/CheckTaikoInconsistentSkipBarLineTest.cs @@ -0,0 +1,220 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Edit.Checks; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Taiko.Tests.Editor.Checks +{ + [TestFixture] + public class CheckTaikoInconsistentSkipBarLineTest + { + private CheckTaikoInconsistentSkipBarLine check = null!; + + [SetUp] + public void Setup() + { + check = new CheckTaikoInconsistentSkipBarLine(); + } + + [Test] + public void TestConsistentOmitFirstBarLine() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, false), (2000.0, true) } // Same settings + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestInconsistentOmitFirstBarLine() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, false) } // Different settings + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.All(issue => issue.Template is CheckTaikoInconsistentSkipBarLine.IssueTemplateInconsistentOmitFirstBarLine)); + Assert.That(issues[0].Time, Is.EqualTo(1000.0)); + Assert.That(issues[1].Time, Is.EqualTo(2000.0)); + } + + [Test] + public void TestPartiallyInconsistentOmitFirstBarLine() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true), (3000.0, false) }, // Reference + new[] { (1000.0, false), (2000.0, false), (3000.0, false) } // Only second differs + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckTaikoInconsistentSkipBarLine.IssueTemplateInconsistentOmitFirstBarLine); + Assert.That(issues[0].Time, Is.EqualTo(2000.0)); + } + + [Test] + public void TestSingleDifficulty() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) } // Only one difficulty + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestNonTaikoBeatmaps() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, false) } // Different settings + ); + + // Make both beatmaps non-taiko + beatmaps[0].BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + beatmaps[1].BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMixedRulesets() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, false) } // Different settings + ); + + // Make reference taiko, other non-taiko + beatmaps[0].BeatmapInfo.Ruleset = new TaikoRuleset().RulesetInfo; + beatmaps[1].BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMissingTimingPoints() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference has 2 points + new[] { (1000.0, false) } // Other has only 1 point (missing 2000.0) + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + // Should only check the existing timing point at 1000.0 (consistent, no issue) + // The missing 2000.0 point should be ignored by this check + Assert.That(issues, Is.Empty); + } + + [Test] + public void TestExtraTimingPoints() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false) }, // Reference has 1 point + new[] { (1000.0, false), (2000.0, true) } // Other has extra point + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + // Should only check the existing timing point at 1000.0 (consistent, no issue) + // The extra 2000.0 point should be ignored by this check + Assert.That(issues, Is.Empty); + } + + [Test] + public void TestMultipleDifficultiesWithInconsistencies() + { + var beatmaps = createBeatmapSetWithTimingPoints( + new[] { (1000.0, false), (2000.0, true) }, // Reference + new[] { (1000.0, true), (2000.0, true) }, // First differs + new[] { (1000.0, false), (2000.0, false) } // Second differs + ); + + var context = createContextWithMultipleDifficulties(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + // Should have issues for both other difficulties + Assert.That(issues, Has.Count.EqualTo(2)); // 1000.0 from diff2, 2000.0 from diff3 + Assert.That(issues.All(issue => issue.Template is CheckTaikoInconsistentSkipBarLine.IssueTemplateInconsistentOmitFirstBarLine)); + Assert.That(issues[0].Time, Is.EqualTo(1000.0)); + Assert.That(issues[1].Time, Is.EqualTo(2000.0)); + } + + private IBeatmap[] createBeatmapSetWithTimingPoints(params (double time, bool omitFirstBarLine)[][] timingData) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingData.Length]; + + for (int i = 0; i < timingData.Length; i++) + { + beatmaps[i] = createBeatmapWithTimingPoints(timingData[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + beatmaps[i].BeatmapInfo.Ruleset = new TaikoRuleset().RulesetInfo; + } + + // Configure the beatmapset to contain all the beatmap infos + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap createBeatmapWithTimingPoints((double time, bool omitFirstBarLine)[] timingData, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + } + }; + + foreach ((double time, bool omitFirstBarLine) in timingData) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = 500, // Standard BPM + OmitFirstBarLine = omitFirstBarLine + }); + } + + return beatmap; + } + + private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IBeatmap[] allDifficulties) + { + var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap); + var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList(); + + return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs index c523652ae1..0199e98af0 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs @@ -3,9 +3,7 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Tests.Visual; @@ -31,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Editor AddStep("hover over first hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(1))); AddStep("hover over second hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(0))); AddStep("right click", () => InputManager.Click(MouseButton.Right)); - AddUntilStep("context menu open", () => Editor.ChildrenOfType().Any(menu => menu.State == MenuState.Open)); + AddUntilStep("second hit deleted", () => Editor.ChildrenOfType().Count(), () => Is.EqualTo(1)); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs index 6fe61e78b7..c175e3342b 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs @@ -171,13 +171,13 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 6 }); beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 10 }); - var hitWindows = new HitWindows(); + var hitWindows = new DefaultHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); PerformTest(new List { new TaikoReplayFrame(0), - new TaikoReplayFrame(hit_time - hitWindows.WindowFor(HitResult.Great), TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time - (hitWindows.WindowFor(HitResult.Great) + 0.1), TaikoAction.LeftCentre), }, beatmap); AssertJudgementCount(1); diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs index 05a408c621..6dbd3478f1 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs @@ -3,7 +3,10 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.UI; using osuTK; @@ -12,6 +15,34 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods { public partial class TestSceneTaikoModFlashlight : TaikoModTestScene { + [Test] + public void TestAspectRatios([Values] bool withClassicMod) + { + if (withClassicMod) + CreateModTest(new ModTestData { Mods = new Mod[] { new TaikoModFlashlight(), new TaikoModClassic() }, PassCondition = () => true }); + else + CreateModTest(new ModTestData { Mod = new TaikoModFlashlight(), PassCondition = () => true }); + + AddStep("clear dim", () => LocalConfig.SetValue(OsuSetting.DimLevel, 0.0)); + + AddStep("reset", () => Stack.FillMode = FillMode.Stretch); + AddStep("set to 16:9", () => + { + Stack.FillAspectRatio = 16 / 9f; + Stack.FillMode = FillMode.Fit; + }); + AddStep("set to 4:3", () => + { + Stack.FillAspectRatio = 4 / 3f; + Stack.FillMode = FillMode.Fit; + }); + AddSliderStep("aspect ratio", 0.01f, 5f, 1f, v => + { + Stack.FillAspectRatio = v; + Stack.FillMode = FillMode.Fit; + }); + } + [TestCase(1f)] [TestCase(0.5f)] [TestCase(1.25f)] diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs index e6d5c51902..5336ea604e 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs @@ -5,10 +5,13 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Tests.Mods { @@ -69,5 +72,106 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods }, }); } + + [Test] + public void TestIncreasedVisibilityOnFirstObject() + { + bool firstHitNeverFadedOut = true; + AddStep("enable increased visibility", () => LocalConfig.SetValue(OsuSetting.IncreaseFirstObjectVisibility, true)); + CreateModTest(new ModTestData + { + Mod = new TaikoModHidden(), + Autoplay = true, + PassCondition = () => + { + var firstHit = this.ChildrenOfType().FirstOrDefault(h => h.HitObject.StartTime == 100); + + if (firstHit?.Alpha < 1 && !firstHit.IsHit) + firstHitNeverFadedOut = false; + + return firstHitNeverFadedOut && checkAllMaxResultJudgements(2).Invoke(); + }, + CreateBeatmap = () => + { + var beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + Type = HitType.Rim, + StartTime = 100, + }, + new Hit + { + Type = HitType.Centre, + StartTime = 200, + }, + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty + { + SliderTickRate = 4, + OverallDifficulty = 0, + }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); + return beatmap; + }, + }); + } + + [Test] + public void TestNoIncreasedVisibilityOnFirstObject() + { + bool firstHitFadedOut = true; + AddStep("enable increased visibility", () => LocalConfig.SetValue(OsuSetting.IncreaseFirstObjectVisibility, false)); + CreateModTest(new ModTestData + { + Mod = new TaikoModHidden(), + Autoplay = true, + PassCondition = () => + { + var firstHit = this.ChildrenOfType().FirstOrDefault(h => h.HitObject.StartTime == 100); + firstHitFadedOut |= firstHit?.IsHit == false && firstHit.Alpha < 1; + return firstHitFadedOut && checkAllMaxResultJudgements(2).Invoke(); + }, + CreateBeatmap = () => + { + var beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + Type = HitType.Rim, + StartTime = 100, + }, + new Hit + { + Type = HitType.Centre, + StartTime = 200, + }, + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty + { + SliderTickRate = 4, + OverallDifficulty = 0, + }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); + return beatmap; + }, + }); + } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSimplifiedRhythm.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSimplifiedRhythm.cs new file mode 100644 index 0000000000..565b9c3362 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSimplifiedRhythm.cs @@ -0,0 +1,243 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public partial class TestSceneTaikoModSimplifiedRhythm : TaikoModTestScene + { + [Test] + public void TestOneThirdConversion() + { + CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneThirdConversion = { Value = true }, + }, + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1500, Type = HitType.Centre }, + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2333, Type = HitType.Rim }, // mod removes this + new Hit { StartTime = 2666, Type = HitType.Centre }, // mod moves this to 2500 + new Hit { StartTime = 3000, Type = HitType.Centre }, + new Hit { StartTime = 3500, Type = HitType.Centre }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1200), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1700), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2200), + new TaikoReplayFrame(2500, TaikoAction.LeftCentre), + new TaikoReplayFrame(2700), + new TaikoReplayFrame(3000, TaikoAction.LeftCentre), + new TaikoReplayFrame(3200), + new TaikoReplayFrame(3500, TaikoAction.LeftCentre), + new TaikoReplayFrame(3700), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 6 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + } + + [Test] + public void TestOneSixthConversion() => CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneSixthConversion = { Value = true } + }, + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1250, Type = HitType.Centre }, + new Hit { StartTime = 1500, Type = HitType.Centre }, + new Hit { StartTime = 1666, Type = HitType.Rim }, // mod removes this + new Hit { StartTime = 1833, Type = HitType.Centre }, // mod moves this to 1750 + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2250, Type = HitType.Centre }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1200), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre), + new TaikoReplayFrame(1450), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1600), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre), + new TaikoReplayFrame(1800), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2200), + new TaikoReplayFrame(2250, TaikoAction.LeftCentre), + new TaikoReplayFrame(2450), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 6 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + + [Test] + public void TestOneEighthConversion() => CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneEighthConversion = { Value = true } + }, + Autoplay = false, + CreateBeatmap = () => + { + const double one_eighth_timing = 125; + + return new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1250, Type = HitType.Centre }, + new Hit { StartTime = 1500, Type = HitType.Centre }, + new Hit { StartTime = 1500 + one_eighth_timing * 1, Type = HitType.Rim }, // mod removes this + new Hit { StartTime = 1500 + one_eighth_timing * 2 }, + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 1, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2000 + one_eighth_timing * 2, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 3, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2000 + one_eighth_timing * 4, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 5, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2000 + one_eighth_timing * 6, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 7, Type = HitType.Centre }, // mod removes this + }, + }; + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1000), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre), + new TaikoReplayFrame(1250), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1500), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre), + new TaikoReplayFrame(1750), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2000), + new TaikoReplayFrame(2250, TaikoAction.LeftCentre), + new TaikoReplayFrame(2250), + new TaikoReplayFrame(2500, TaikoAction.LeftCentre), + new TaikoReplayFrame(2500), + new TaikoReplayFrame(2750, TaikoAction.LeftCentre), + new TaikoReplayFrame(2750), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 8 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + + /// + /// Regression tests a case of 1/3rd conversion where there are exactly div-3 number of hitobjects. + /// + [Test] + public void TestOnlyOneThirdConversion() + { + CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneThirdConversion = { Value = true }, + }, + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1333, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 1666, Type = HitType.Centre }, // mod moves this to 1500 + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2333, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2666, Type = HitType.Centre }, // mod moves this to 2500 + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1200), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1700), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2200), + new TaikoReplayFrame(2500, TaikoAction.LeftCentre), + new TaikoReplayFrame(2700), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 4 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + } + + /// + /// Regression tests a case of 1/6th conversion where there are exactly div-6 number of hitobjects. + /// + [Test] + public void TestOnlyOneSixthConversion() => CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneSixthConversion = { Value = true } + }, + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1166, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 1333, Type = HitType.Centre }, // mod moves this to 1250 + new Hit { StartTime = 1500, Type = HitType.Centre }, + new Hit { StartTime = 1666, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 1833, Type = HitType.Centre }, // mod moves this to 1750 + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2166, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2333, Type = HitType.Centre }, // mod moves this to 2250 + new Hit { StartTime = 2500, Type = HitType.Centre }, + new Hit { StartTime = 2666, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2833, Type = HitType.Centre }, // mod moves this to 2750 + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1200), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre), + new TaikoReplayFrame(1450), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1600), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre), + new TaikoReplayFrame(1800), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2200), + new TaikoReplayFrame(2250, TaikoAction.LeftCentre), + new TaikoReplayFrame(2450), + new TaikoReplayFrame(2500, TaikoAction.LeftCentre), + new TaikoReplayFrame(2600), + new TaikoReplayFrame(2750, TaikoAction.LeftCentre), + new TaikoReplayFrame(2800), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 8 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png deleted file mode 100755 index 5aba688756..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini deleted file mode 100644 index 462c2c278e..0000000000 --- a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini +++ /dev/null @@ -1,5 +0,0 @@ -[General] -Name: an old skin -Author: an old guy - -// no version specified means v1 \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png deleted file mode 100644 index ad55fd5a96..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png deleted file mode 100644 index f5c02509fb..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png deleted file mode 100644 index 53905792cb..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png deleted file mode 100644 index 2d9974a701..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png deleted file mode 100644 index 07b2f167e0..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png deleted file mode 100644 index 63504dd52d..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png deleted file mode 100644 index 490c196fba..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png deleted file mode 100644 index 99cd589a10..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png deleted file mode 100644 index 26eec54d07..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png deleted file mode 100644 index 272c6bcaf7..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png deleted file mode 100644 index e49e82a71f..0000000000 Binary files a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png and /dev/null differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs index c130b5f366..286b16aa34 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { Anchor = Anchor.Centre, Origin = Anchor.Centre, + Scale = new osuTK.Vector2(0.5f), })); } diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index 09d6540f72..a4b33b7c15 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(3.0920212594351191d, 200, "diffcalc-test")] - [TestCase(3.0920212594351191d, 200, "diffcalc-test-strong")] + [TestCase(3.3190848563395079d, 200, "diffcalc-test")] + [TestCase(3.3190848563395079d, 200, "diffcalc-test-strong")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(4.0789820318081444d, 200, "diffcalc-test")] - [TestCase(4.0789820318081444d, 200, "diffcalc-test-strong")] + [TestCase(4.4551414906554987d, 200, "diffcalc-test")] + [TestCase(4.4551414906554987d, 200, "diffcalc-test-strong")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime()); diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoRateAdjustedDisplayDifficultyTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoRateAdjustedDisplayDifficultyTest.cs index 4ab3f502ad..0fb92e0d7d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoRateAdjustedDisplayDifficultyTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoRateAdjustedDisplayDifficultyTest.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Mods; namespace osu.Game.Rulesets.Taiko.Tests { @@ -21,8 +22,9 @@ namespace osu.Game.Rulesets.Taiko.Tests { var ruleset = new TaikoRuleset(); var difficulty = new BeatmapDifficulty { OverallDifficulty = originalOverallDifficulty }; + var beatmapInfo = new BeatmapInfo { Difficulty = difficulty }; - var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1); + var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, []); Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(originalOverallDifficulty)); } @@ -32,8 +34,9 @@ namespace osu.Game.Rulesets.Taiko.Tests { var ruleset = new TaikoRuleset(); var difficulty = new BeatmapDifficulty(); + var beatmapInfo = new BeatmapInfo { Difficulty = difficulty }; - var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 0.75); + var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, [new TaikoModHalfTime()]); Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(1.11).Within(0.01)); } @@ -43,8 +46,9 @@ namespace osu.Game.Rulesets.Taiko.Tests { var ruleset = new TaikoRuleset(); var difficulty = new BeatmapDifficulty(); + var beatmapInfo = new BeatmapInfo { Difficulty = difficulty }; - var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1.5); + var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, [new TaikoModDoubleTime()]); Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(8.89).Within(0.01)); } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs new file mode 100644 index 0000000000..40a426b360 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs @@ -0,0 +1,210 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene + { + protected override string? ExportLocation => null; + + protected override Ruleset CreateRuleset() => new TaikoRuleset(); + + private static readonly object[][] no_mod_test_cases = + { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + + // OD = 5 test cases. + // GREAT hit window is (-35ms, 35ms) + // OK hit window is (-80ms, 80ms) + new object[] { 5f, -33d, HitResult.Great }, + new object[] { 5f, -34d, HitResult.Great }, + new object[] { 5f, -35d, HitResult.Ok }, + new object[] { 5f, -36d, HitResult.Ok }, + new object[] { 5f, -78d, HitResult.Ok }, + new object[] { 5f, -79d, HitResult.Ok }, + new object[] { 5f, -80d, HitResult.Miss }, + new object[] { 5f, -81d, HitResult.Miss }, + + // OD = 7.8 test cases. + // GREAT hit window is (-26ms, 26ms) + // OK hit window is (-63ms, 63ms) + new object[] { 7.8f, -24d, HitResult.Great }, + new object[] { 7.8f, -25d, HitResult.Great }, + new object[] { 7.8f, -26d, HitResult.Ok }, + new object[] { 7.8f, -27d, HitResult.Ok }, + new object[] { 7.8f, -61d, HitResult.Ok }, + new object[] { 7.8f, -62d, HitResult.Ok }, + new object[] { 7.8f, -63d, HitResult.Miss }, + new object[] { 7.8f, -64d, HitResult.Miss }, + }; + + private static readonly object[][] hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // GREAT hit window is (-29ms, 29ms) + // OK hit window is (-68ms, 68ms) + new object[] { 5f, -27d, HitResult.Great }, + new object[] { 5f, -28d, HitResult.Great }, + new object[] { 5f, -29d, HitResult.Ok }, + new object[] { 5f, -30d, HitResult.Ok }, + new object[] { 5f, -66d, HitResult.Ok }, + new object[] { 5f, -67d, HitResult.Ok }, + new object[] { 5f, -68d, HitResult.Miss }, + new object[] { 5f, -69d, HitResult.Miss }, + + // OD = 7.8 test cases. + // This would lead to "effective" OD of 10.92, + // but the effects are capped to OD 10. + // GREAT hit window is (-20ms, 20ms) + // OK hit window is (-50ms, 50ms) + new object[] { 7.8f, -18d, HitResult.Great }, + new object[] { 7.8f, -19d, HitResult.Great }, + new object[] { 7.8f, -20d, HitResult.Ok }, + new object[] { 7.8f, -21d, HitResult.Ok }, + new object[] { 7.8f, -48d, HitResult.Ok }, + new object[] { 7.8f, -49d, HitResult.Ok }, + new object[] { 7.8f, -50d, HitResult.Miss }, + new object[] { 7.8f, -51d, HitResult.Miss }, + }; + + private static readonly object[][] easy_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 2.5. + // GREAT hit window is ( -42ms, 42ms) + // OK hit window is (-100ms, 100ms) + new object[] { 5f, -40d, HitResult.Great }, + new object[] { 5f, -41d, HitResult.Great }, + new object[] { 5f, -42d, HitResult.Ok }, + new object[] { 5f, -43d, HitResult.Ok }, + new object[] { 5f, -98d, HitResult.Ok }, + new object[] { 5f, -99d, HitResult.Ok }, + new object[] { 5f, -100d, HitResult.Miss }, + new object[] { 5f, -101d, HitResult.Miss }, + }; + + private const double hit_time = 100; + + [TestCaseSource(nameof(no_mod_test_cases))] + public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + } + }; + + RunTest($@"single hit @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(hard_rock_test_cases))] + public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new TaikoModHardRock()] + } + }; + + RunTest($@"HR single hit @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(easy_test_cases))] + public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new TaikoModEasy()] + } + }; + + RunTest($@"EZ single hit @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static TaikoBeatmap createBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new TaikoBeatmap + { + HitObjects = + { + new Hit + { + StartTime = hit_time, + Type = HitType.Centre, + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new TaikoRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayRecording.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayRecording.cs new file mode 100644 index 0000000000..bd79bbe8cf --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayRecording.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public partial class TestSceneReplayRecording : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + + [Resolved] + private AudioManager audioManager { get; set; } = null!; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = + { + new Hit { StartTime = 0, }, + new Hit { StartTime = 5000, }, + new Hit { StartTime = 10000, }, + new Hit { StartTime = 15000, } + } + }; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => + new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + [Test] + public void TestRecording() + { + seekTo(0); + AddStep("press D", () => InputManager.PressKey(Key.D)); + seekTo(15); + AddStep("release D", () => InputManager.ReleaseKey(Key.D)); + AddAssert("left rim press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([TaikoAction.LeftRim]))); + + seekTo(5000); + AddStep("press F", () => InputManager.PressKey(Key.F)); + seekTo(5015); + AddStep("release F", () => InputManager.ReleaseKey(Key.F)); + AddAssert("left centre press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([TaikoAction.LeftCentre]))); + + seekTo(10000); + AddStep("press J", () => InputManager.PressKey(Key.J)); + seekTo(10015); + AddStep("release J", () => InputManager.ReleaseKey(Key.J)); + AddAssert("right centre press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([TaikoAction.RightCentre]))); + + seekTo(15000); + AddStep("press K", () => InputManager.PressKey(Key.K)); + seekTo(15015); + AddStep("release K", () => InputManager.ReleaseKey(Key.K)); + AddAssert("right rim press recorded to replay", () => Player.Score.Replay.Frames.OfType().Any(f => f.Actions.SequenceEqual([TaikoAction.RightRim]))); + } + + private void seekTo(double time) + { + AddStep($"seek to {time}ms", () => Player.GameplayClockContainer.Seek(time)); + AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(500)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs new file mode 100644 index 0000000000..c61ae8ecc7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneReplayStability.cs @@ -0,0 +1,93 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public partial class TestSceneReplayStability : ReplayStabilityTestScene + { + private static readonly object[][] test_cases = + { + // With respect to notation, + // square brackets `[]` represent *closed* or *inclusive* bounds, + // while round brackets `()` represent *open* or *exclusive* bounds. + + // OD = 5 test cases. + // GREAT hit window is [-34.5ms, 34.5ms] + // OK hit window is [-79.5ms, 79.5ms] + // MISS hit window is [-94.5ms, 94.5ms] + new object[] { 5f, -34d, HitResult.Great }, + new object[] { 5f, -34.2d, HitResult.Great }, + new object[] { 5f, -34.7d, HitResult.Ok }, + new object[] { 5f, -35d, HitResult.Ok }, + new object[] { 5f, -35.2d, HitResult.Ok }, + new object[] { 5f, -35.8d, HitResult.Ok }, + new object[] { 5f, -36d, HitResult.Ok }, + new object[] { 5f, -79d, HitResult.Ok }, + new object[] { 5f, -79.3d, HitResult.Ok }, + new object[] { 5f, -79.7d, HitResult.Miss }, + new object[] { 5f, -80d, HitResult.Miss }, + new object[] { 5f, -80.2d, HitResult.Miss }, + new object[] { 5f, -80.8d, HitResult.Miss }, + new object[] { 5f, -81d, HitResult.Miss }, + + // OD = 7.8 test cases. + // GREAT hit window is [-25.5ms, 25.5ms] + // OK hit window is [-62.5ms, 62.5ms] + // MISS hit window is [-80.5ms, 80.5ms] + new object[] { 7.8f, -25d, HitResult.Great }, + new object[] { 7.8f, -25.4d, HitResult.Great }, + new object[] { 7.8f, -25.8d, HitResult.Ok }, + new object[] { 7.8f, -26d, HitResult.Ok }, + new object[] { 7.8f, -26.1d, HitResult.Ok }, + new object[] { 7.8f, -62d, HitResult.Ok }, + new object[] { 7.8f, -62.4d, HitResult.Ok }, + new object[] { 7.8f, -62.7d, HitResult.Miss }, + new object[] { 7.8f, -63d, HitResult.Miss }, + new object[] { 7.8f, -63.2d, HitResult.Miss }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestHitWindowStability(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + const double hit_time = 100; + + var beatmap = new TaikoBeatmap + { + HitObjects = + { + new Hit + { + StartTime = hit_time, + Type = HitType.Centre, + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new TaikoRuleset().RulesetInfo, + }, + }; + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + RunTest(beatmap, replay, [expectedResult]); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs index 1d1e82fb07..b1df133c30 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs @@ -14,26 +14,27 @@ namespace osu.Game.Rulesets.Taiko.Tests protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); - [TestCase("taiko-normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + [TestCase("taiko-normal-hitnormal2", "taiko-normal-hitnormal")] + [TestCase("hitnormal", "hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(expectedSample, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); - AssertBeatmapLookup(expectedSample); + AssertBeatmapLookup(beatmapSkinSampleName); } - [TestCase("taiko-normal-hitnormal")] - [TestCase("hitnormal")] - public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + [TestCase("", "taiko-normal-hitnormal")] + [TestCase("taiko-normal-hitnormal", "taiko-normal-hitnormal")] + [TestCase("", "hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string beatmapSkinSampleName, string userSkinSampleName) { - SetupSkins(string.Empty, expectedSample); + SetupSkins(beatmapSkinSampleName, userSkinSampleName); CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); - AssertUserLookup(expectedSample); + AssertUserLookup(userSkinSampleName); } [TestCase("taiko-normal-hitnormal2")] 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 2170009ae8..e498989a79 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 @@ -1,9 +1,9 @@  - + - + WinExe diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs index 41fe63a553..4a38381bbe 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Localisation; using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Beatmaps @@ -15,26 +17,30 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps int hits = HitObjects.Count(s => s is Hit); int drumRolls = HitObjects.Count(s => s is DrumRoll); int swells = HitObjects.Count(s => s is Swell); + int sum = Math.Max(1, hits + drumRolls); return new[] { new BeatmapStatistic { - Name = @"Hit Count", + Name = BeatmapStatisticStrings.Hits, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), Content = hits.ToString(), + BarDisplayLength = hits / (float)sum, }, new BeatmapStatistic { - Name = @"Drumroll Count", + Name = BeatmapStatisticStrings.Drumrolls, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), Content = drumRolls.ToString(), + BarDisplayLength = drumRolls / (float)sum, }, new BeatmapStatistic { - Name = @"Swell Count", + Name = BeatmapStatisticStrings.Swells, CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), Content = swells.ToString(), + BarDisplayLength = Math.Min(swells / 10f, 1), } }; } diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index 010b1f0a7a..7d66820e5a 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -146,7 +146,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps case IHasDuration endTimeData: { - double hitMultiplier = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.OverallDifficulty, 3, 5, 7.5) * swell_hit_multiplier; + double hitMultiplier = RequiredSwellHitsPerSecond(beatmap.Difficulty.OverallDifficulty); yield return new Swell { @@ -172,6 +172,9 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps } } + public static double RequiredSwellHitsPerSecond(double overallDifficulty) + => IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, 3, 5, 7.5) * swell_hit_multiplier; + private bool shouldConvertSliderToHits(HitObject obj, IBeatmap beatmap, IHasPath pathData, out int taikoDuration, out double tickSpacing) { // DO NOT CHANGE OR REFACTOR ANYTHING IN HERE WITHOUT TESTING AGAINST _ALL_ BEATMAPS. @@ -210,7 +213,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps double osuVelocity = taikoVelocity * (1000f / beatLength); // osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8 - if (beatmap.BeatmapInfo.BeatmapVersion >= 8) + if (beatmap.BeatmapVersion >= 8) beatLength = timingPoint.BeatLength; // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs index 25428c8b2f..d8d30e3fef 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; @@ -10,45 +12,91 @@ using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { - public class ColourEvaluator + public static class ColourEvaluator { /// - /// Evaluate the difficulty of the first note of a . + /// Calculates a consistency penalty based on the number of consecutive consistent intervals, + /// considering the delta time between each colour sequence. /// - public static double EvaluateDifficultyOf(MonoStreak monoStreak) + /// The current hitObject to consider. + /// The allowable margin of error for determining whether ratios are consistent. + /// The maximum objects to check per count of consistent ratio. + private static double consistentRatioPenalty(TaikoDifficultyHitObject hitObject, double threshold = 0.01, int maxObjectsToCheck = 64) { - return DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5; + int consistentRatioCount = 0; + double totalRatioCount = 0.0; + + List recentRatios = new List(); + TaikoDifficultyHitObject current = hitObject; + var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1); + + for (int i = 0; i < maxObjectsToCheck; i++) + { + // Break if there is no valid previous object + if (current.Index <= 1) + break; + + double currentRatio = current.RhythmData.Ratio; + double previousRatio = previousHitObject.RhythmData.Ratio; + + recentRatios.Add(currentRatio); + + // A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error. + if (Math.Abs(1 - currentRatio / previousRatio) <= threshold) + { + consistentRatioCount++; + totalRatioCount += currentRatio; + break; + } + + current = previousHitObject; + } + + // Ensure no division by zero + if (consistentRatioCount > 0) + return 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80; + + if (recentRatios.Count <= 1) return 1.0; + + // As a fallback, calculate the maximum deviation from the average of the recent ratios to ensure slightly off-snapped objects don't bypass the penalty. + double maxRatioDeviation = recentRatios.Max(r => Math.Abs(r - recentRatios.Average())); + + double consistentRatioPenalty = 0.7 + 0.3 * DifficultyCalculationUtils.Smootherstep(maxRatioDeviation, 0.0, 1.0); + + return consistentRatioPenalty; } /// - /// Evaluate the difficulty of the first note of a . + /// Evaluate the difficulty of the first hitobject within a colour streak. /// - public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern) - { - return DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * EvaluateDifficultyOf(alternatingMonoPattern.Parent); - } - - /// - /// Evaluate the difficulty of the first note of a . - /// - public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern) - { - return 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E)); - } - public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) { - TaikoDifficultyHitObjectColour colour = ((TaikoDifficultyHitObject)hitObject).Colour; + var taikoObject = (TaikoDifficultyHitObject)hitObject; + TaikoColourData colourData = taikoObject.ColourData; double difficulty = 0.0d; - if (colour.MonoStreak?.FirstHitObject == hitObject) // Difficulty for MonoStreak - difficulty += EvaluateDifficultyOf(colour.MonoStreak); - if (colour.AlternatingMonoPattern?.FirstHitObject == hitObject) // Difficulty for AlternatingMonoPattern - difficulty += EvaluateDifficultyOf(colour.AlternatingMonoPattern); - if (colour.RepeatingHitPattern?.FirstHitObject == hitObject) // Difficulty for RepeatingHitPattern - difficulty += EvaluateDifficultyOf(colour.RepeatingHitPattern); + if (colourData.MonoStreak?.FirstHitObject == hitObject) // Difficulty for MonoStreak + difficulty += evaluateMonoStreakDifficulty(colourData.MonoStreak); + + if (colourData.AlternatingMonoPattern?.FirstHitObject == hitObject) // Difficulty for AlternatingMonoPattern + difficulty += evaluateAlternatingMonoPatternDifficulty(colourData.AlternatingMonoPattern); + + if (colourData.RepeatingHitPattern?.FirstHitObject == hitObject) // Difficulty for RepeatingHitPattern + difficulty += evaluateRepeatingHitPatternsDifficulty(colourData.RepeatingHitPattern); + + double consistencyPenalty = consistentRatioPenalty(taikoObject); + difficulty *= consistencyPenalty; return difficulty; } + + private static double evaluateMonoStreakDifficulty(MonoStreak monoStreak) => + DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * evaluateAlternatingMonoPatternDifficulty(monoStreak.Parent) * 0.5; + + private static double evaluateAlternatingMonoPatternDifficulty(AlternatingMonoPattern alternatingMonoPattern) => + DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * evaluateRepeatingHitPatternsDifficulty(alternatingMonoPattern.Parent); + + private static double evaluateRepeatingHitPatternsDifficulty(RepeatingHitPatterns repeatingHitPattern) => + 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E)); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs new file mode 100644 index 0000000000..5871979613 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators +{ + public static class ReadingEvaluator + { + private readonly struct VelocityRange + { + public double Min { get; } + public double Max { get; } + public double Center => (Max + Min) / 2; + public double Range => Max - Min; + + public VelocityRange(double min, double max) + { + Min = min; + Max = max; + } + } + + /// + /// Calculates the influence of higher slider velocities on hitobject difficulty. + /// The bonus is determined based on the EffectiveBPM, shifting within a defined range + /// between the upper and lower boundaries to reflect how increased slider velocity impacts difficulty. + /// + /// The hit object to evaluate. + /// The reading difficulty value for the given hit object. + public static double EvaluateDifficultyOf(TaikoDifficultyHitObject noteObject) + { + var highVelocity = new VelocityRange(480, 640); + var midVelocity = new VelocityRange(360, 480); + + // Apply a cap to prevent outlier values on maps that exceed the editor's parameters. + double effectiveBPM = Math.Max(1.0, noteObject.EffectiveBPM); + + double midVelocityDifficulty = 0.5 * DifficultyCalculationUtils.Logistic(effectiveBPM, midVelocity.Center, 1.0 / (midVelocity.Range / 10)); + + // Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. + double expectedDeltaTime = 21000.0 / effectiveBPM; + double objectDensity = expectedDeltaTime / Math.Max(1.0, noteObject.DeltaTime); + + // High density is penalised at high velocity as it is generally considered easier to read. See https://www.desmos.com/calculator/u63f3ntdsi + double densityPenalty = DifficultyCalculationUtils.Logistic(objectDensity, 0.925, 15); + + double highVelocityDifficulty = (1.0 - 0.33 * densityPenalty) + * DifficultyCalculationUtils.Logistic(effectiveBPM, highVelocity.Center + 8 * densityPenalty, (1.0 + 0.5 * densityPenalty) / (highVelocity.Range / 10)); + + return midVelocityDifficulty + highVelocityDifficulty; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs new file mode 100644 index 0000000000..3b3aea07f3 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -0,0 +1,156 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators +{ + public class RhythmEvaluator + { + /// + /// Evaluate the difficulty of a hitobject considering its interval change. + /// + public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow) + { + TaikoRhythmData rhythmData = ((TaikoDifficultyHitObject)hitObject).RhythmData; + double difficulty = 0.0d; + + double sameRhythm = 0; + double samePattern = 0; + double intervalPenalty = 0; + + if (rhythmData.SameRhythmGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmGroupedHitObjects + { + sameRhythm += 10.0 * evaluateDifficultyOf(rhythmData.SameRhythmGroupedHitObjects, hitWindow); + intervalPenalty = repeatedIntervalPenalty(rhythmData.SameRhythmGroupedHitObjects, hitWindow); + } + + if (rhythmData.SamePatternsGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SamePatternsGroupedHitObjects + samePattern += 1.15 * ratioDifficulty(rhythmData.SamePatternsGroupedHitObjects.IntervalRatio); + + difficulty += Math.Max(sameRhythm, samePattern) * intervalPenalty; + + return difficulty; + } + + private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhythmGroupedHitObjects, double hitWindow) + { + double intervalDifficulty = ratioDifficulty(sameRhythmGroupedHitObjects.HitObjectIntervalRatio); + double? previousInterval = sameRhythmGroupedHitObjects.Previous?.HitObjectInterval; + + intervalDifficulty *= repeatedIntervalPenalty(sameRhythmGroupedHitObjects, hitWindow); + + // If a previous interval exists and there are multiple hit objects in the sequence: + if (previousInterval != null && sameRhythmGroupedHitObjects.HitObjects.Count > 1) + { + double expectedDurationFromPrevious = (double)previousInterval * sameRhythmGroupedHitObjects.HitObjects.Count; + double durationDifference = sameRhythmGroupedHitObjects.Duration - expectedDurationFromPrevious; + + if (durationDifference > 0) + { + intervalDifficulty *= DifficultyCalculationUtils.Logistic( + durationDifference / hitWindow, + midpointOffset: 0.7, + multiplier: 1.0, + maxValue: 1); + } + } + + // Penalise patterns that can be hit within a single hit window. + intervalDifficulty *= DifficultyCalculationUtils.Logistic( + sameRhythmGroupedHitObjects.Duration / hitWindow, + midpointOffset: 0.6, + multiplier: 1, + maxValue: 1); + + return Math.Pow(intervalDifficulty, 0.75); + } + + /// + /// Determines if the changes in hit object intervals is consistent based on a given threshold. + /// + private static double repeatedIntervalPenalty(SameRhythmHitObjectGrouping sameRhythmGroupedHitObjects, double hitWindow, double threshold = 0.1) + { + double longIntervalPenalty = sameInterval(sameRhythmGroupedHitObjects, 3); + + double shortIntervalPenalty = sameRhythmGroupedHitObjects.HitObjects.Count < 6 + ? sameInterval(sameRhythmGroupedHitObjects, 4) + : 1.0; // Returns a non-penalty if there are 6 or more notes within an interval. + + // The duration penalty is based on hit object duration relative to hitWindow. + double durationPenalty = Math.Max(1 - sameRhythmGroupedHitObjects.Duration * 2 / hitWindow, 0.5); + + return Math.Min(longIntervalPenalty, shortIntervalPenalty) * durationPenalty; + + double sameInterval(SameRhythmHitObjectGrouping startObject, int intervalCount) + { + List intervals = new List(); + var currentObject = startObject; + + for (int i = 0; i < intervalCount && currentObject != null; i++) + { + intervals.Add(currentObject.HitObjectInterval); + currentObject = currentObject.Previous; + } + + intervals.RemoveAll(interval => interval == null); + + if (intervals.Count < intervalCount) + return 1.0; // No penalty if there aren't enough valid intervals. + + for (int i = 0; i < intervals.Count; i++) + { + for (int j = i + 1; j < intervals.Count; j++) + { + double ratio = intervals[i]!.Value / intervals[j]!.Value; + if (Math.Abs(1 - ratio) <= threshold) // If any two intervals are similar, apply a penalty. + return 0.80; + } + } + + return 1.0; // No penalty if all intervals are different. + } + } + + /// + /// Calculates the difficulty of a given ratio using a combination of periodic penalties and bonuses. + /// + private static double ratioDifficulty(double ratio, int terms = 8) + { + double difficulty = 0; + + // Validate the ratio by ensuring it is a normal number in cases where maps breach regular mapping conditions. + ratio = double.IsNormal(ratio) ? ratio : 0; + + for (int i = 1; i <= terms; ++i) + { + difficulty += termPenalty(ratio, i, 4, 1); + } + + difficulty += terms / (1 + ratio); + + // Give bonus to near-1 ratios + difficulty += DifficultyCalculationUtils.BellCurve(ratio, 1, 0.5); + + // Penalize ratios that are VERY near 1 + difficulty -= DifficultyCalculationUtils.BellCurve(ratio, 1, 0.3); + + difficulty = Math.Max(difficulty, 0); + difficulty /= Math.Sqrt(8); + + return difficulty; + } + + /// + /// Multiplier for a given denominator term. + /// + private static double termPenalty(double ratio, int denominator, double power, double multiplier) => + -multiplier * Math.Pow(Math.Cos(denominator * Math.PI * ratio), power); + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs index 84d5de4c63..32ed8ec189 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs @@ -8,43 +8,8 @@ using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { - public class StaminaEvaluator + public static class StaminaEvaluator { - /// - /// Applies a speed bonus dependent on the time since the last hit performed using this finger. - /// - /// The interval between the current and previous note hit using the same finger. - private static double speedBonus(double interval) - { - // Interval is capped at a very small value to prevent infinite values. - interval = Math.Max(interval, 1); - - return 30 / interval; - } - - /// - /// Determines the number of fingers available to hit the current . - /// Any mono notes that is more than 300ms apart from a colour change will be considered to have more than 2 - /// fingers available, since players can hit the same key with multiple fingers. - /// - private static int availableFingersFor(TaikoDifficultyHitObject hitObject) - { - DifficultyHitObject? previousColourChange = hitObject.Colour.PreviousColourChange; - DifficultyHitObject? nextColourChange = hitObject.Colour.NextColourChange; - - if (previousColourChange != null && hitObject.StartTime - previousColourChange.StartTime < 300) - { - return 2; - } - - if (nextColourChange != null && nextColourChange.StartTime - hitObject.StartTime < 300) - { - return 2; - } - - return 4; - } - /// /// Evaluates the minimum mechanical stamina required to play the current object. This is calculated using the /// maximum possible interval between two hits using the same key, by alternating available fingers for each colour. @@ -59,17 +24,51 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators // Find the previous hit object hit by the current finger, which is n notes prior, n being the number of // available fingers. TaikoDifficultyHitObject taikoCurrent = (TaikoDifficultyHitObject)current; - TaikoDifficultyHitObject? keyPrevious = taikoCurrent.PreviousMono(availableFingersFor(taikoCurrent) - 1); - - if (keyPrevious == null) - { - // There is no previous hit object hit by the current finger - return 0.0; - } + TaikoDifficultyHitObject? taikoPrevious = current.Previous(1) as TaikoDifficultyHitObject; + TaikoDifficultyHitObject? previousMono = taikoCurrent.PreviousMono(availableFingersFor(taikoCurrent) - 1); double objectStrain = 0.5; // Add a base strain to all objects - objectStrain += speedBonus(taikoCurrent.StartTime - keyPrevious.StartTime); + if (taikoPrevious == null) return objectStrain; + + if (previousMono != null) + objectStrain += speedBonus(taikoCurrent.StartTime - previousMono.StartTime) + 0.5 * speedBonus(taikoCurrent.StartTime - taikoPrevious.StartTime); + return objectStrain; } + + /// + /// Applies a speed bonus dependent on the time since the last hit performed using this finger. + /// + /// The interval between the current and previous note hit using the same finger. + private static double speedBonus(double interval) + { + // Interval is capped at a very small value to prevent infinite values. + interval = Math.Max(interval, 1); + + return 20 / interval; + } + + /// + /// Determines the number of fingers available to hit the current . + /// Any mono notes that is more than 300ms apart from a colour change will be considered to have more than 2 + /// fingers available, since players can hit the same key with multiple fingers. + /// + private static int availableFingersFor(TaikoDifficultyHitObject hitObject) + { + DifficultyHitObject? previousColourChange = hitObject.ColourData.PreviousColourChange; + DifficultyHitObject? nextColourChange = hitObject.ColourData.NextColourChange; + + if (previousColourChange != null && hitObject.StartTime - previousColourChange.StartTime < 300) + { + return 2; + } + + if (nextColourChange != null && nextColourChange.StartTime - hitObject.StartTime < 300) + { + return 2; + } + + return 8; + } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourData.cs similarity index 96% rename from osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs rename to osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourData.cs index abf6fb3672..81201b6584 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourData.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour /// /// Stores colour compression information for a . /// - public class TaikoDifficultyHitObjectColour + public class TaikoColourData { /// /// The that encodes this note. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs index 18a299ae92..3c6ef7c53c 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs @@ -14,8 +14,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour public static class TaikoColourDifficultyPreprocessor { /// - /// Processes and encodes a list of s into a list of s, - /// assigning the appropriate s to each . + /// Processes and encodes a list of s into a list of s, + /// assigning the appropriate s to each . /// public static void ProcessAndAssign(List hitObjects) { @@ -41,9 +41,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour foreach (var hitObject in monoStreak.HitObjects) { - hitObject.Colour.RepeatingHitPattern = repeatingHitPattern; - hitObject.Colour.AlternatingMonoPattern = monoPattern; - hitObject.Colour.MonoStreak = monoStreak; + hitObject.ColourData.RepeatingHitPattern = repeatingHitPattern; + hitObject.ColourData.AlternatingMonoPattern = monoPattern; + hitObject.ColourData.MonoStreak = monoStreak; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatternsGroupedHitObjects.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatternsGroupedHitObjects.cs new file mode 100644 index 0000000000..938cb4670f --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatternsGroupedHitObjects.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data +{ + /// + /// Represents grouped by their 's interval. + /// + public class SamePatternsGroupedHitObjects + { + public IReadOnlyList Groups { get; } + + public SamePatternsGroupedHitObjects? Previous { get; } + + /// + /// The between groups . + /// If there is only one group, this will have the value of the first group's . + /// + public double GroupInterval => Groups.Count > 1 ? Groups[1].Interval : Groups[0].Interval; + + /// + /// The ratio of between this and the previous . In the + /// case where there is no previous , this will have a value of 1. + /// + public double IntervalRatio => GroupInterval / Previous?.GroupInterval ?? 1.0d; + + public TaikoDifficultyHitObject FirstHitObject => Groups[0].FirstHitObject; + + public IEnumerable AllHitObjects => Groups.SelectMany(hitObject => hitObject.HitObjects); + + public SamePatternsGroupedHitObjects(SamePatternsGroupedHitObjects? previous, List groups) + { + Previous = previous; + Groups = groups; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs new file mode 100644 index 0000000000..59215c043b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjectGrouping.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Utils; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data +{ + /// + /// Represents a group of s with no rhythm variation. + /// + public class SameRhythmHitObjectGrouping : IHasInterval + { + public readonly List HitObjects; + + public TaikoDifficultyHitObject FirstHitObject => HitObjects[0]; + + public readonly SameRhythmHitObjectGrouping? Previous; + + private const double snap_tolerance = IntervalGroupingUtils.MARGIN_OF_ERROR; + + /// + /// of the first hit object. + /// + public double StartTime => HitObjects[0].StartTime; + + /// + /// The interval between the first and final hit object within this group. + /// + public double Duration => HitObjects[^1].StartTime - HitObjects[0].StartTime; + + /// + /// The normalised interval in ms of each hit object in this . This is only defined if there is + /// more than two hit objects in this . + /// + public readonly double? HitObjectInterval; + + /// + /// The normalised ratio of between this and the previous . In the + /// case where one or both of the is undefined, this will have a value of 1. + /// + public readonly double HitObjectIntervalRatio; + + /// + public double Interval { get; } = double.PositiveInfinity; + + public SameRhythmHitObjectGrouping(SameRhythmHitObjectGrouping? previous, List hitObjects) + { + Previous = previous; + HitObjects = hitObjects; + + // Cluster and normalise each hitobjects delta-time. + var normaliseHitObjects = DeltaTimeNormaliser.Normalise(hitObjects, snap_tolerance); + + var normalisedHitObjectDeltaTime = hitObjects + .Skip(1) + .Select(hitObject => normaliseHitObjects[hitObject]) + .ToList(); + + // Secondary check to ensure there isn't any 'noise' or outliers by taking the modal delta time. + double modalDelta = normalisedHitObjectDeltaTime.Count > 0 + ? Math.Round(normalisedHitObjectDeltaTime[0]) + : 0; + + // Calculate the average interval between hitobjects. + if (normalisedHitObjectDeltaTime.Count > 0) + { + if (previous?.HitObjectInterval is double previousDelta && Math.Abs(modalDelta - previousDelta) <= snap_tolerance) + HitObjectInterval = previousDelta; + else + HitObjectInterval = modalDelta; + } + + // Calculate the ratio between this group's interval and the previous group's interval + HitObjectIntervalRatio = previous?.HitObjectInterval is double previousInterval && HitObjectInterval is double currentInterval + ? currentInterval / previousInterval + : 1.0; + + // Calculate the interval from the previous group's start time + if (previous != null) + { + if (Math.Abs(StartTime - previous.StartTime) <= snap_tolerance) + Interval = 0; + else + Interval = StartTime - previous.StartTime; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs deleted file mode 100644 index a273d7e2ea..0000000000 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm -{ - /// - /// Represents a rhythm change in a taiko map. - /// - public class TaikoDifficultyHitObjectRhythm - { - /// - /// The difficulty multiplier associated with this rhythm change. - /// - public readonly double Difficulty; - - /// - /// The ratio of current - /// to previous for the rhythm change. - /// A above 1 indicates a slow-down; a below 1 indicates a speed-up. - /// - public readonly double Ratio; - - /// - /// Creates an object representing a rhythm change. - /// - /// The numerator for . - /// The denominator for - /// The difficulty multiplier associated with this rhythm change. - public TaikoDifficultyHitObjectRhythm(int numerator, int denominator, double difficulty) - { - Ratio = numerator / (double)denominator; - Difficulty = difficulty; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoRhythmData.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoRhythmData.cs new file mode 100644 index 0000000000..247fb06dc0 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoRhythmData.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.Linq; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm +{ + /// + /// Stores rhythm data for a . + /// + public class TaikoRhythmData + { + /// + /// The group of hit objects with consistent rhythm that this object belongs to. + /// + public SameRhythmHitObjectGrouping? SameRhythmGroupedHitObjects; + + /// + /// The larger pattern of rhythm groups that this object is part of. + /// + public SamePatternsGroupedHitObjects? SamePatternsGroupedHitObjects; + + /// + /// The ratio of current + /// to previous for the rhythm change. + /// A above 1 indicates a slow-down; a below 1 indicates a speed-up. + /// + /// + /// This is snapped to the closest matching . + /// + public readonly double Ratio; + + /// + /// Initialises a new instance of s, + /// calculating the closest rhythm change and its associated difficulty for the current hit object. + /// + /// The current being processed. + public TaikoRhythmData(TaikoDifficultyHitObject current) + { + var previous = current.Previous(0); + + if (previous == null) + { + Ratio = 1; + return; + } + + double actualRatio = current.DeltaTime / previous.DeltaTime; + double closestRatio = common_ratios.MinBy(r => Math.Abs(r - actualRatio)); + + Ratio = closestRatio; + } + + /// + /// List of most common rhythm changes in taiko maps. Based on how each object's interval compares to the previous object. + /// + /// + /// The general guidelines for the values are: + /// + /// rhythm changes with ratio closer to 1 (that are not 1) are harder to play, + /// speeding up is generally harder than slowing down (with exceptions of rhythm changes requiring a hand switch). + /// + /// + private static readonly double[] common_ratios = + [ + 1.0 / 1, + 2.0 / 1, + 1.0 / 2, + 3.0 / 1, + 1.0 / 3, + 3.0 / 2, + 2.0 / 3, + 5.0 / 4, + 4.0 / 5 + ]; + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoRhythmDifficultyPreprocessor.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoRhythmDifficultyPreprocessor.cs new file mode 100644 index 0000000000..5bc0fdbc03 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoRhythmDifficultyPreprocessor.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; +using osu.Game.Rulesets.Taiko.Difficulty.Utils; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm +{ + public static class TaikoRhythmDifficultyPreprocessor + { + public static void ProcessAndAssign(List hitObjects) + { + var rhythmGroups = createSameRhythmGroupedHitObjects(hitObjects); + + foreach (var rhythmGroup in rhythmGroups) + { + foreach (var hitObject in rhythmGroup.HitObjects) + hitObject.RhythmData.SameRhythmGroupedHitObjects = rhythmGroup; + } + + var patternGroups = createSamePatternGroupedHitObjects(rhythmGroups); + + foreach (var patternGroup in patternGroups) + { + foreach (var hitObject in patternGroup.AllHitObjects) + hitObject.RhythmData.SamePatternsGroupedHitObjects = patternGroup; + } + } + + private static List createSameRhythmGroupedHitObjects(List hitObjects) + { + var rhythmGroups = new List(); + + foreach (var grouped in IntervalGroupingUtils.GroupByInterval(hitObjects)) + rhythmGroups.Add(new SameRhythmHitObjectGrouping(rhythmGroups.LastOrDefault(), grouped)); + + return rhythmGroups; + } + + private static List createSamePatternGroupedHitObjects(List rhythmGroups) + { + var patternGroups = new List(); + + foreach (var grouped in IntervalGroupingUtils.GroupByInterval(rhythmGroups)) + patternGroups.Add(new SamePatternsGroupedHitObjects(patternGroups.LastOrDefault(), grouped)); + + return patternGroups; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index 4aaee50c18..f407e13ff1 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -1,21 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; +using osu.Game.Rulesets.Taiko.Difficulty.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing { /// /// Represents a single hit object in taiko difficulty calculation. /// - public class TaikoDifficultyHitObject : DifficultyHitObject + public class TaikoDifficultyHitObject : DifficultyHitObject, IHasInterval { /// /// The list of all of the same colour as this in the beatmap. @@ -38,98 +40,89 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing public readonly int NoteIndex; /// - /// The rhythm required to hit this hit object. + /// Rhythm data used by . + /// This is populated via . /// - public readonly TaikoDifficultyHitObjectRhythm Rhythm; + public readonly TaikoRhythmData RhythmData; /// - /// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used - /// by other skills in the future. + /// Colour data used by and . + /// This is populated via . /// - public readonly TaikoDifficultyHitObjectColour Colour; + public readonly TaikoColourData ColourData; + + /// + /// The adjusted BPM of this hit object, based on its slider velocity and scroll speed. + /// + public double EffectiveBPM; /// /// Creates a new difficulty hit object. /// /// The gameplay associated with this difficulty object. /// The gameplay preceding . - /// The gameplay preceding . /// The rate of the gameplay clock. Modified by speed-changing mods. /// The list of all s in the current beatmap. /// The list of centre (don) s in the current beatmap. /// The list of rim (kat) s in the current beatmap. /// The list of s that is a hit (i.e. not a drumroll or swell) in the current beatmap. /// The position of this in the list. - public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, + /// The control point info of the beatmap. + /// The global slider velocity of the beatmap. + public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, List centreHitObjects, List rimHitObjects, - List noteObjects, int index) + List noteObjects, int index, + ControlPointInfo controlPointInfo, + double globalSliderVelocity) : base(hitObject, lastObject, clockRate, objects, index) { noteDifficultyHitObjects = noteObjects; - // Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor - Colour = new TaikoDifficultyHitObjectColour(); - Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate); + ColourData = new TaikoColourData(); + RhythmData = new TaikoRhythmData(this); - switch ((hitObject as Hit)?.Type) + if (hitObject is Hit hit) { - case HitType.Centre: - MonoIndex = centreHitObjects.Count; - centreHitObjects.Add(this); - monoDifficultyHitObjects = centreHitObjects; - break; + switch (hit.Type) + { + case HitType.Centre: + MonoIndex = centreHitObjects.Count; + centreHitObjects.Add(this); + monoDifficultyHitObjects = centreHitObjects; + break; - case HitType.Rim: - MonoIndex = rimHitObjects.Count; - rimHitObjects.Add(this); - monoDifficultyHitObjects = rimHitObjects; - break; - } + case HitType.Rim: + MonoIndex = rimHitObjects.Count; + rimHitObjects.Add(this); + monoDifficultyHitObjects = rimHitObjects; + break; + } - if (hitObject is Hit) - { NoteIndex = noteObjects.Count; noteObjects.Add(this); } + + // Using `hitObject.StartTime` causes floating point error differences + double normalisedStartTime = StartTime * clockRate; + + // Retrieve the timing point at the note's start time + TimingControlPoint currentControlPoint = controlPointInfo.TimingPointAt(normalisedStartTime); + + // Calculate the slider velocity at the note's start time. + double currentSliderVelocity = calculateSliderVelocity(controlPointInfo, globalSliderVelocity, normalisedStartTime, clockRate); + + EffectiveBPM = currentControlPoint.BPM * currentSliderVelocity; } /// - /// List of most common rhythm changes in taiko maps. + /// Calculates the slider velocity based on control point info and clock rate. /// - /// - /// The general guidelines for the values are: - /// - /// rhythm changes with ratio closer to 1 (that are not 1) are harder to play, - /// speeding up is generally harder than slowing down (with exceptions of rhythm changes requiring a hand switch). - /// - /// - private static readonly TaikoDifficultyHitObjectRhythm[] common_rhythms = + private static double calculateSliderVelocity(ControlPointInfo controlPointInfo, double globalSliderVelocity, double startTime, double clockRate) { - new TaikoDifficultyHitObjectRhythm(1, 1, 0.0), - new TaikoDifficultyHitObjectRhythm(2, 1, 0.3), - new TaikoDifficultyHitObjectRhythm(1, 2, 0.5), - new TaikoDifficultyHitObjectRhythm(3, 1, 0.3), - new TaikoDifficultyHitObjectRhythm(1, 3, 0.35), - new TaikoDifficultyHitObjectRhythm(3, 2, 0.6), // purposefully higher (requires hand switch in full alternating gameplay style) - new TaikoDifficultyHitObjectRhythm(2, 3, 0.4), - new TaikoDifficultyHitObjectRhythm(5, 4, 0.5), - new TaikoDifficultyHitObjectRhythm(4, 5, 0.7) - }; - - /// - /// Returns the closest rhythm change from required to hit this object. - /// - /// The gameplay preceding this one. - /// The gameplay preceding . - /// The rate of the gameplay clock. - private TaikoDifficultyHitObjectRhythm getClosestRhythm(HitObject lastObject, HitObject lastLastObject, double clockRate) - { - double prevLength = (lastObject.StartTime - lastLastObject.StartTime) / clockRate; - double ratio = DeltaTime / prevLength; - - return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First(); + var activeEffectControlPoint = controlPointInfo.EffectPointAt(startTime); + return globalSliderVelocity * (activeEffectControlPoint.ScrollSpeed) * clockRate; } public TaikoDifficultyHitObject? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1)); @@ -139,5 +132,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing public TaikoDifficultyHitObject? PreviousNote(int backwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex - (backwardsIndex + 1)); public TaikoDifficultyHitObject? NextNote(int forwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex + (forwardsIndex + 1)); + + public double Interval => DeltaTime; } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs new file mode 100644 index 0000000000..7be1107b70 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Skills +{ + /// + /// Calculates the reading coefficient of taiko difficulty. + /// + public class Reading : StrainDecaySkill + { + protected override double SkillMultiplier => 1.0; + protected override double StrainDecayBase => 0.4; + + private double currentStrain; + + public Reading(Mod[] mods) + : base(mods) + { + } + + protected override double StrainValueOf(DifficultyHitObject current) + { + // Drum Rolls and Swells are exempt. + if (current.BaseObject is not Hit) + { + return 0.0; + } + + var taikoObject = (TaikoDifficultyHitObject)current; + int index = taikoObject.ColourData.MonoStreak?.HitObjects.IndexOf(taikoObject) ?? 0; + + currentStrain *= DifficultyCalculationUtils.Logistic(index, 4, -1 / 25.0, 0.5) + 0.5; + + currentStrain *= StrainDecayBase; + currentStrain += ReadingEvaluator.EvaluateDifficultyOf(taikoObject) * SkillMultiplier; + + return currentStrain; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index e76af13686..45d0d0a548 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; -using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Utils; +using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { @@ -16,158 +14,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// public class Rhythm : StrainDecaySkill { - protected override double SkillMultiplier => 10; - protected override double StrainDecayBase => 0; + protected override double SkillMultiplier => 1.0; + protected override double StrainDecayBase => 0.4; - /// - /// The note-based decay for rhythm strain. - /// - /// - /// is not used here, as it's time- and not note-based. - /// - private const double strain_decay = 0.96; + private readonly double greatHitWindow; - /// - /// Maximum number of entries in . - /// - private const int rhythm_history_max_length = 8; - - /// - /// Contains the last changes in note sequence rhythms. - /// - private readonly LimitedCapacityQueue rhythmHistory = new LimitedCapacityQueue(rhythm_history_max_length); - - /// - /// Contains the rolling rhythm strain. - /// Used to apply per-note decay. - /// - private double currentStrain; - - /// - /// Number of notes since the last rhythm change has taken place. - /// - private int notesSinceRhythmChange; - - public Rhythm(Mod[] mods) + public Rhythm(Mod[] mods, double greatHitWindow) : base(mods) { + this.greatHitWindow = greatHitWindow; } protected override double StrainValueOf(DifficultyHitObject current) { - // drum rolls and swells are exempt. - if (!(current.BaseObject is Hit)) - { - resetRhythmAndStrain(); - return 0.0; - } + double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow); - currentStrain *= strain_decay; + // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty. + double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) - 0.5; // Remove base strain + difficulty *= DifficultyCalculationUtils.Logistic(staminaDifficulty, 1 / 15.0, 50.0); - TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current; - notesSinceRhythmChange += 1; - - // rhythm difficulty zero (due to rhythm not changing) => no rhythm strain. - if (hitObject.Rhythm.Difficulty == 0.0) - { - return 0.0; - } - - double objectStrain = hitObject.Rhythm.Difficulty; - - objectStrain *= repetitionPenalties(hitObject); - objectStrain *= patternLengthPenalty(notesSinceRhythmChange); - objectStrain *= speedPenalty(hitObject.DeltaTime); - - // careful - needs to be done here since calls above read this value - notesSinceRhythmChange = 0; - - currentStrain += objectStrain; - return currentStrain; - } - - /// - /// Returns a penalty to apply to the current hit object caused by repeating rhythm changes. - /// - /// - /// Repetitions of more recent patterns are associated with a higher penalty. - /// - /// The current hit object being considered. - private double repetitionPenalties(TaikoDifficultyHitObject hitObject) - { - double penalty = 1; - - rhythmHistory.Enqueue(hitObject); - - for (int mostRecentPatternsToCompare = 2; mostRecentPatternsToCompare <= rhythm_history_max_length / 2; mostRecentPatternsToCompare++) - { - for (int start = rhythmHistory.Count - mostRecentPatternsToCompare - 1; start >= 0; start--) - { - if (!samePattern(start, mostRecentPatternsToCompare)) - continue; - - int notesSince = hitObject.Index - rhythmHistory[start].Index; - penalty *= repetitionPenalty(notesSince); - break; - } - } - - return penalty; - } - - /// - /// Determines whether the rhythm change pattern starting at is a repeat of any of the - /// . - /// - private bool samePattern(int start, int mostRecentPatternsToCompare) - { - for (int i = 0; i < mostRecentPatternsToCompare; i++) - { - if (rhythmHistory[start + i].Rhythm != rhythmHistory[rhythmHistory.Count - mostRecentPatternsToCompare + i].Rhythm) - return false; - } - - return true; - } - - /// - /// Calculates a single rhythm repetition penalty. - /// - /// Number of notes since the last repetition of a rhythm change. - private static double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince); - - /// - /// Calculates a penalty based on the number of notes since the last rhythm change. - /// Both rare and frequent rhythm changes are penalised. - /// - /// Number of notes since the last rhythm change. - private static double patternLengthPenalty(int patternLength) - { - double shortPatternPenalty = Math.Min(0.15 * patternLength, 1.0); - double longPatternPenalty = Math.Clamp(2.5 - 0.15 * patternLength, 0.0, 1.0); - return Math.Min(shortPatternPenalty, longPatternPenalty); - } - - /// - /// Calculates a penalty for objects that do not require alternating hands. - /// - /// Time (in milliseconds) since the last hit object. - private double speedPenalty(double deltaTime) - { - if (deltaTime < 80) return 1; - if (deltaTime < 210) return Math.Max(0, 1.4 - 0.005 * deltaTime); - - resetRhythmAndStrain(); - return 0.0; - } - - /// - /// Resets the rolling strain value and counter. - /// - private void resetRhythmAndStrain() - { - currentStrain = 0.0; - notesSinceRhythmChange = 0; + return difficulty; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index f6914039f0..5e18163fe0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -4,6 +4,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; @@ -18,7 +19,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills private double skillMultiplier => 1.1; private double strainDecayBase => 0.4; - private readonly bool singleColourStamina; + public readonly bool SingleColourStamina; + private readonly bool isConvert; private double currentStrain; @@ -27,10 +29,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// Mods for use in skill calculations. /// Reads when Stamina is from a single coloured pattern. - public Stamina(Mod[] mods, bool singleColourStamina) + /// Determines if the currently evaluated beatmap is converted. + public Stamina(Mod[] mods, bool singleColourStamina, bool isConvert) : base(mods) { - this.singleColourStamina = singleColourStamina; + SingleColourStamina = singleColourStamina; + this.isConvert = isConvert; } private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); @@ -38,18 +42,28 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; // Safely prevents previous strains from shifting as new notes are added. var currentObject = current as TaikoDifficultyHitObject; - int index = currentObject?.Colour.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; + int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; - if (singleColourStamina) - return currentStrain / (1 + Math.Exp(-(index - 10) / 2.0)); + double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DifficultyCalculationUtils.ReverseLerp(index, 5, 20); - return currentStrain; + // Mono-streak bonus is only applied to colour-based stamina to reward longer sequences of same-colour hits within patterns. + if (!SingleColourStamina) + staminaDifficulty *= monoLengthBonus; + + currentStrain += staminaDifficulty; + + // For converted maps, difficulty often comes entirely from long mono streams with no colour variation. + // To avoid over-rewarding these maps based purely on stamina strain, we dampen the strain value once the index exceeds 10. + return SingleColourStamina ? DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain) : currentStrain; } - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => singleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => + SingleColourStamina + ? 0 + : currentStrain * strainDecay(time - current.Previous(0).StartTime); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index c8f0448767..c5cc04449c 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -10,10 +10,31 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyAttributes : DifficultyAttributes { + /// + /// The difficulty corresponding to the mechanical skills in osu!taiko. + /// This includes colour and stamina combined. + /// + public double MechanicalDifficulty { get; set; } + + /// + /// The difficulty corresponding to the rhythm skill. + /// + [JsonProperty("rhythm_difficulty")] + public double RhythmDifficulty { get; set; } + + /// + /// The difficulty corresponding to the reading skill. + /// + public double ReadingDifficulty { get; set; } + + /// + /// The difficulty corresponding to the colour skill. + /// + public double ColourDifficulty { get; set; } + /// /// The difficulty corresponding to the stamina skill. /// - [JsonProperty("stamina_difficulty")] public double StaminaDifficulty { get; set; } /// @@ -23,40 +44,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public double MonoStaminaFactor { get; set; } /// - /// The difficulty corresponding to the rhythm skill. + /// The factor corresponding to the consistency of a map. /// - [JsonProperty("rhythm_difficulty")] - public double RhythmDifficulty { get; set; } + [JsonProperty("consistency_factor")] + public double ConsistencyFactor { get; set; } - /// - /// The difficulty corresponding to the colour skill. - /// - [JsonProperty("colour_difficulty")] - public double ColourDifficulty { get; set; } - - /// - /// The difficulty corresponding to the hardest parts of the map. - /// - [JsonProperty("peak_difficulty")] - public double PeakDifficulty { get; set; } - - /// - /// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc). - /// - /// - /// Rate-adjusting mods don't directly affect the hit window, but have a perceived effect as a result of adjusting audio timing. - /// - [JsonProperty("great_hit_window")] - public double GreatHitWindow { get; set; } - - /// - /// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc). - /// - /// - /// Rate-adjusting mods don't directly affect the hit window, but have a perceived effect as a result of adjusting audio timing. - /// - [JsonProperty("ok_hit_window")] - public double OkHitWindow { get; set; } + public double StaminaTopStrains { get; set; } public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() { @@ -64,9 +57,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return v; yield return (ATTRIB_ID_DIFFICULTY, StarRating); - yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); - yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow); + yield return (ATTRIB_ID_RHYTHM_DIFFICULTY, RhythmDifficulty); yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); + yield return (ATTRIB_ID_CONSISTENCY_FACTOR, ConsistencyFactor); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -74,9 +67,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty base.FromDatabaseAttributes(values, onlineInfo); StarRating = values[ATTRIB_ID_DIFFICULTY]; - GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; - OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW]; + RhythmDifficulty = values[ATTRIB_ID_RHYTHM_DIFFICULTY]; MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; + ConsistencyFactor = values[ATTRIB_ID_CONSISTENCY_FACTOR]; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 7f2558c406..edd26819f5 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -8,10 +8,12 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Scoring; @@ -21,11 +23,18 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public class TaikoDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.084375; - private const double rhythm_skill_multiplier = 0.2 * difficulty_multiplier; + private const double rhythm_skill_multiplier = 0.750 * difficulty_multiplier; + private const double reading_skill_multiplier = 0.100 * difficulty_multiplier; private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; - private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier; + private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier; - public override int Version => 20241007; + private double strainLengthBonus; + private double patternMultiplier; + + private bool isRelax; + private bool isConvert; + + public override int Version => 20251020; public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -34,12 +43,19 @@ namespace osu.Game.Rulesets.Taiko.Difficulty protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { + HitWindows hitWindows = new TaikoHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; + isRelax = mods.Any(h => h is TaikoModRelax); + return new Skill[] { - new Rhythm(mods), + new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate), + new Reading(mods), new Colour(mods), - new Stamina(mods, false), - new Stamina(mods, true) + new Stamina(mods, false, isConvert), + new Stamina(mods, true, isConvert) }; } @@ -53,21 +69,30 @@ namespace osu.Game.Rulesets.Taiko.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - List difficultyHitObjects = new List(); - List centreObjects = new List(); - List rimObjects = new List(); - List noteObjects = new List(); + var difficultyHitObjects = new List(); + var centreObjects = new List(); + var rimObjects = new List(); + var noteObjects = new List(); + // Generate TaikoDifficultyHitObjects from the beatmap's hit objects. for (int i = 2; i < beatmap.HitObjects.Count; i++) { - difficultyHitObjects.Add( - new TaikoDifficultyHitObject( - beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, difficultyHitObjects, - centreObjects, rimObjects, noteObjects, difficultyHitObjects.Count) - ); + difficultyHitObjects.Add(new TaikoDifficultyHitObject( + beatmap.HitObjects[i], + beatmap.HitObjects[i - 1], + clockRate, + difficultyHitObjects, + centreObjects, + rimObjects, + noteObjects, + difficultyHitObjects.Count, + beatmap.ControlPointInfo, + beatmap.Difficulty.SliderMultiplier + )); } TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects); + TaikoRhythmDifficultyPreprocessor.ProcessAndAssign(noteObjects); return difficultyHitObjects; } @@ -77,60 +102,56 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; - Colour colour = (Colour)skills.First(x => x is Colour); - Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm); - Stamina stamina = (Stamina)skills.First(x => x is Stamina); - Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina); + var rhythm = skills.OfType().Single(); + var reading = skills.OfType().Single(); + var colour = skills.OfType().Single(); + var stamina = skills.OfType().Single(s => !s.SingleColourStamina); + var singleColourStamina = skills.OfType().Single(s => s.SingleColourStamina); - double colourRating = colour.DifficultyValue() * colour_skill_multiplier; - double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; - double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5); + double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier; + double readingSkill = reading.DifficultyValue() * reading_skill_multiplier; + double colourSkill = colour.DifficultyValue() * colour_skill_multiplier; + double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier; + double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; + double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); - double combinedRating = combinedDifficultyValue(rhythm, colour, stamina); + double staminaDifficultStrains = stamina.CountTopWeightedStrains(); + + // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. + patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); + + strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); + + double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor); double starRating = rescale(combinedRating * 1.4); - // TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system. - if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0) - { - starRating *= 0.925; - // For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused. - if (colourRating < 2 && staminaRating > 8) - starRating *= 0.80; - } + // Calculate proportional contribution of each skill to the combinedRating. + double skillRating = starRating / (rhythmSkill + readingSkill + colourSkill + staminaSkill); - HitWindows hitWindows = new TaikoHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + double rhythmDifficulty = rhythmSkill * skillRating; + double readingDifficulty = readingSkill * skillRating; + double colourDifficulty = colourSkill * skillRating; + double staminaDifficulty = staminaSkill * skillRating; + double mechanicalDifficulty = colourDifficulty + staminaDifficulty; // Mechanical difficulty is the sum of colour and stamina difficulties. TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes { StarRating = starRating, Mods = mods, - StaminaDifficulty = staminaRating, + MechanicalDifficulty = mechanicalDifficulty, + RhythmDifficulty = rhythmDifficulty, + ReadingDifficulty = readingDifficulty, + ColourDifficulty = colourDifficulty, + StaminaDifficulty = staminaDifficulty, MonoStaminaFactor = monoStaminaFactor, - RhythmDifficulty = rhythmRating, - ColourDifficulty = colourRating, - PeakDifficulty = combinedRating, - GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, - OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate, + StaminaTopStrains = staminaDifficultStrains, + ConsistencyFactor = consistencyFactor, MaxCombo = beatmap.GetMaxCombo(), }; return attributes; } - /// - /// Applies a final re-scaling of the star rating. - /// - /// The raw star rating value before re-scaling. - private double rescale(double sr) - { - if (sr < 0) return sr; - - return 10.43 * Math.Log(sr / 8 + 1); - } - /// /// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map. /// @@ -138,27 +159,19 @@ namespace osu.Game.Rulesets.Taiko.Difficulty /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). /// - private double combinedDifficultyValue(Rhythm rhythm, Colour colour, Stamina stamina) + private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, out double consistencyFactor) { - List peaks = new List(); + List peaks = combinePeaks( + rhythm.GetCurrentStrainPeaks().ToList(), + reading.GetCurrentStrainPeaks().ToList(), + colour.GetCurrentStrainPeaks().ToList(), + stamina.GetCurrentStrainPeaks().ToList() + ); - var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); - var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); - var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); - - for (int i = 0; i < colourPeaks.Count; i++) + if (peaks.Count == 0) { - double colourPeak = colourPeaks[i] * colour_skill_multiplier; - double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; - double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier; - - double peak = norm(1.5, colourPeak, staminaPeak); - peak = norm(2, peak, rhythmPeak); - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - if (peak > 0) - peaks.Add(peak); + consistencyFactor = 0; + return 0; } double difficulty = 0; @@ -170,14 +183,65 @@ namespace osu.Game.Rulesets.Taiko.Difficulty weight *= 0.9; } + List hitObjectStrainPeaks = combinePeaks( + rhythm.GetObjectStrains().ToList(), + reading.GetObjectStrains().ToList(), + colour.GetObjectStrains().ToList(), + stamina.GetObjectStrains().ToList() + ); + + if (hitObjectStrainPeaks.Count == 0) + { + consistencyFactor = 0; + return 0; + } + + // The average of the top 5% of strain peaks from hit objects. + double topAverageHitObjectStrain = hitObjectStrainPeaks.OrderDescending().Take(1 + hitObjectStrainPeaks.Count / 20).Average(); + + // Calculates a consistency factor as the sum of difficulty from hit objects compared to if every object were as hard as the hardest. + // The top average strain is used instead of the very hardest to prevent exceptionally hard objects lowering the factor. + consistencyFactor = hitObjectStrainPeaks.Sum() / (topAverageHitObjectStrain * hitObjectStrainPeaks.Count); + return difficulty; } /// - /// Returns the p-norm of an n-dimensional vector. + /// Combines lists of peak strains from multiple skills into a list of single peak strains for each section. /// - /// The value of p to calculate the norm for. - /// The coefficients of the vector. - private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + private List combinePeaks(List rhythmPeaks, List readingPeaks, List colourPeaks, List staminaPeaks) + { + var combinedPeaks = new List(); + + for (int i = 0; i < colourPeaks.Count; i++) + { + double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier * patternMultiplier; + double readingPeak = readingPeaks[i] * reading_skill_multiplier; + double colourPeak = isRelax ? 0 : colourPeaks[i] * colour_skill_multiplier; // There is no colour difficulty in relax. + double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * strainLengthBonus; + staminaPeak /= isConvert || isRelax ? 1.5 : 1.0; // Available finger count is increased by 150%, thus we adjust accordingly. + + double peak = DifficultyCalculationUtils.Norm(2, DifficultyCalculationUtils.Norm(1.5, colourPeak, staminaPeak), rhythmPeak, readingPeak); + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + if (peak > 0) + combinedPeaks.Add(peak); + } + + return combinedPeaks; + } + + /// + /// Applies a final re-scaling of the star rating. + /// + /// The raw star rating value before re-scaling. + private static double rescale(double sr) + { + if (sr < 0) + return sr; + + return 10.43 * Math.Log(sr / 8 + 1); + } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs index 7c74e43db1..ef40c2e58b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("accuracy")] public double Accuracy { get; set; } - [JsonProperty("effective_miss_count")] - public double EffectiveMissCount { get; set; } - [JsonProperty("estimated_unstable_rate")] public double? EstimatedUnstableRate { get; set; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index c672b7a1d9..df9da49c4b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -4,10 +4,13 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Scoring; using osu.Game.Utils; @@ -21,7 +24,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private int countMiss; private double? estimatedUnstableRate; - private double effectiveMissCount; + private double clockRate; + private double greatHitWindow; + + private double totalDifficultHits; public TaikoPerformanceCalculator() : base(new TaikoRuleset()) @@ -36,84 +42,127 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - estimatedUnstableRate = computeDeviationUpperBound(taikoAttributes) * 10; - // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. - if (totalSuccessfulHits > 0) - effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; + clockRate = ModUtils.CalculateRateWithMods(score.Mods); - // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. + var difficulty = score.BeatmapInfo!.Difficulty.Clone(); + + score.Mods.OfType().ForEach(m => m.ApplyToDifficulty(difficulty)); + + HitWindows hitWindows = new TaikoHitWindows(); + hitWindows.SetDifficulty(difficulty.OverallDifficulty); + + greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate; + + estimatedUnstableRate = (countGreat == 0 || greatHitWindow <= 0) + ? null + : computeDeviationUpperBound(countGreat / (double)totalHits) * 10; + + // Total difficult hits measures the total difficulty of a map based on its consistency factor. + totalDifficultHits = totalHits * taikoAttributes.ConsistencyFactor; + + // Converts and the classic mod are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; + bool isClassic = score.Mods.Any(m => m is ModClassic); - double multiplier = 1.13; - - if (score.Mods.Any(m => m is ModHidden) && !isConvert) - multiplier *= 1.075; - - if (score.Mods.Any(m => m is ModEasy)) - multiplier *= 0.950; - - double difficultyValue = computeDifficultyValue(score, taikoAttributes); - double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); - double totalValue = - Math.Pow( - Math.Pow(difficultyValue, 1.1) + - Math.Pow(accuracyValue, 1.1), 1.0 / 1.1 - ) * multiplier; + double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert, isClassic) * 1.08; + double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert) * 1.1; return new TaikoPerformanceAttributes { Difficulty = difficultyValue, Accuracy = accuracyValue, - EffectiveMissCount = effectiveMissCount, EstimatedUnstableRate = estimatedUnstableRate, - Total = totalValue + Total = difficultyValue + accuracyValue }; } - private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert, bool isClassic) { - double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0; + if (estimatedUnstableRate == null || totalDifficultHits == 0) + return 0; - double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); + // The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully. + double rhythmExpectedUnstableRate = computeDeviationUpperBound(1.0) * 10; + + // The unstable rate at which it can be assumed all rhythm difficulty has been ignored. + // 0.8 represents 80% of total hits being greats, or 90% accuracy in-game + double rhythmMaximumUnstableRate = computeDeviationUpperBound(0.8) * 10; + + // The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating. + double rhythmFactor = DifficultyCalculationUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4); + + // A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate. + double rhythmPenalty = 1 - DifficultyCalculationUtils.Logistic( + estimatedUnstableRate.Value, + midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2, + multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate), + maxValue: 0.25 * Math.Pow(rhythmFactor, 3) + ); + + double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating * rhythmPenalty / 0.110) - 4.0; + double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0); + + difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); + + // Applies a bonus to maps with more total difficulty. + double lengthBonus = 1 + 0.25 * totalDifficultHits / (totalDifficultHits + 4000); difficultyValue *= lengthBonus; - difficultyValue *= Math.Pow(0.986, effectiveMissCount); - - if (score.Mods.Any(m => m is ModEasy)) - difficultyValue *= 0.90; + // Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty. + double missPenalty = 0.97 + 0.03 * totalDifficultHits / (totalDifficultHits + 1500); + difficultyValue *= Math.Pow(missPenalty, countMiss); if (score.Mods.Any(m => m is ModHidden)) - difficultyValue *= 1.025; + { + double hiddenBonus = isConvert ? 0.025 : 0.1; - if (score.Mods.Any(m => m is ModHardRock)) - difficultyValue *= 1.10; + // Hidden+flashlight plays are excluded from reading-based penalties to hidden. + if (!score.Mods.Any(m => m is ModFlashlight)) + { + // A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier. + if (!isClassic) + hiddenBonus *= 0.2; + + // A penalty is applied to classic easy+hidden scores, as notes disappear later making fast reading easier. + if (score.Mods.Any(m => m is ModEasy) && isClassic) + hiddenBonus *= 0.5; + } + + difficultyValue *= 1 + hiddenBonus; + } if (score.Mods.Any(m => m is ModFlashlight)) difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); - if (estimatedUnstableRate == null) - return 0; - // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. - double accScalingExponent = 2 + attributes.MonoStaminaFactor; - double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor; + double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor; + double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); - return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); + return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) { - if (attributes.GreatHitWindow <= 0 || estimatedUnstableRate == null) + if (greatHitWindow <= 0 || estimatedUnstableRate == null) return 0; - double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0; + double accuracyValue = 470 * Math.Pow(0.9885, estimatedUnstableRate.Value); - double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + // Scales up the bonus for lower unstable rate as star rating increases. + accuracyValue *= 1 + Math.Pow(50 / estimatedUnstableRate.Value, 2) * Math.Pow(attributes.StarRating, 2.8) / 600; + + if (score.Mods.Any(m => m is ModHidden) && !isConvert) + accuracyValue *= 1.075; + + // Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. + accuracyValue *= 1 + 0.3 * totalDifficultHits / (totalDifficultHits + 4000); + + // Applies a bonus to maps with more total memory required with HDFL. + double memoryLengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); - // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) - accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus); + accuracyValue *= Math.Max(1.0, 1.05 * memoryLengthBonus); return accuracyValue; } @@ -123,58 +172,20 @@ namespace osu.Game.Rulesets.Taiko.Difficulty /// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that /// two SS scores on the same map with the same settings will always return the same deviation. /// - private double? computeDeviationUpperBound(TaikoDifficultyAttributes attributes) + private double computeDeviationUpperBound(double accuracy) { - if (totalSuccessfulHits == 0 || attributes.GreatHitWindow <= 0) - return null; - - double h300 = attributes.GreatHitWindow; - double h100 = attributes.OkHitWindow; - const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed). - // The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window. - double? calcDeviationGreatWindow() - { - if (countGreat == 0) return null; + double n = totalHits; - double n = totalHits; + // Proportion of greats hit. + double p = accuracy; - // Proportion of greats hit. - double p = countGreat / n; + // We can be 99% confident that p is at least this value. + double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); - // We can be 99% confident that p is at least this value. - double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); - - // We can be 99% confident that the deviation is not higher than: - return h300 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound)); - } - - // The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window. - // This will return a lower value than the first method when the number of 100s is high, but the miss count is low. - double? calcDeviationGoodWindow() - { - if (totalSuccessfulHits == 0) return null; - - double n = totalHits; - - // Proportion of greats + goods hit. - double p = totalSuccessfulHits / n; - - // We can be 99% confident that p is at least this value. - double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); - - // We can be 99% confident that the deviation is not higher than: - return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound)); - } - - double? deviationGreatWindow = calcDeviationGreatWindow(); - double? deviationGoodWindow = calcDeviationGoodWindow(); - - if (deviationGreatWindow is null) - return deviationGoodWindow; - - return Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value); + // We can be 99% confident that the deviation is not higher than: + return greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); } private int totalHits => countGreat + countOk + countMeh + countMiss; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Utils/DeltaTimeNormaliser.cs b/osu.Game.Rulesets.Taiko/Difficulty/Utils/DeltaTimeNormaliser.cs new file mode 100644 index 0000000000..5e959f3f25 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Utils/DeltaTimeNormaliser.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Utils +{ + /// + /// Normalises deltaTime values for TaikoDifficultyHitObjects. + /// + public static class DeltaTimeNormaliser + { + /// + /// Combines deltaTime values that differ by at most + /// and replaces each value with the median of its range. This is used to reduce timing noise + /// and improve rhythm grouping consistency, especially for maps with inconsistent or 'off-snapped' timing. + /// + public static Dictionary Normalise( + IReadOnlyList hitObjects, + double marginOfError) + { + var deltaTimes = hitObjects.Select(h => h.DeltaTime).Distinct().OrderBy(d => d).ToList(); + + var sets = new List>(); + List? current = null; + + foreach (double value in deltaTimes) + { + // Add to the current group if within margin of error + if (current != null && Math.Abs(value - current[0]) <= marginOfError) + { + current.Add(value); + continue; + } + + // Otherwise begin a new group + current = new List { value }; + sets.Add(current); + } + + // Compute median for each group + var medianLookup = new Dictionary(); + + foreach (var set in sets) + { + set.Sort(); + int mid = set.Count / 2; + double median = set.Count % 2 == 1 + ? set[mid] + : (set[mid - 1] + set[mid]) / 2; + + foreach (double v in set) + medianLookup[v] = median; + } + + // Assign each hitobjects deltaTime the corresponding median value + return hitObjects.ToDictionary( + h => h, + h => medianLookup.TryGetValue(h.DeltaTime, out double median) ? median : h.DeltaTime + ); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Utils/IHasInterval.cs b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IHasInterval.cs new file mode 100644 index 0000000000..a42940180c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IHasInterval.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Taiko.Difficulty.Utils +{ + /// + /// The interface for objects that provide an interval value. + /// + public interface IHasInterval + { + /// + /// The interval – ie delta time – between this object and a known previous object. + /// + double Interval { get; } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs new file mode 100644 index 0000000000..38129b24e6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Utils/IntervalGroupingUtils.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Utils; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Utils +{ + public static class IntervalGroupingUtils + { + // The margin of error when comparing intervals for grouping, or snapping intervals to a common value. + public const double MARGIN_OF_ERROR = 5.0; + + public static List> GroupByInterval(IReadOnlyList objects) where T : IHasInterval + { + var groups = new List>(); + + int i = 0; + while (i < objects.Count) + groups.Add(createNextGroup(objects, ref i)); + + return groups; + } + + private static List createNextGroup(IReadOnlyList objects, ref int i) where T : IHasInterval + { + // This never compares the first two elements in the group. + // This sounds wrong but is apparently "as intended" (https://github.com/ppy/osu/pull/31636#discussion_r1942673329) + var groupedObjects = new List { objects[i] }; + i++; + + for (; i < objects.Count - 1; i++) + { + if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, MARGIN_OF_ERROR)) + { + // When an interval change occurs, include the object with the differing interval in the case it increased + // See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale. + if (objects[i + 1].Interval > objects[i].Interval + MARGIN_OF_ERROR) + { + groupedObjects.Add(objects[i]); + i++; + } + + return groupedObjects; + } + + // No interval change occurred + groupedObjects.Add(objects[i]); + } + + // Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error. + // If true, add the current object to the group and increment the index to process the next object. + if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, MARGIN_OF_ERROR)) + { + groupedObjects.Add(objects[i]); + i++; + } + + return groupedObjects; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index 7f45123bd6..ce2a674e92 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.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.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Objects; @@ -16,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints public new Hit HitObject => (Hit)base.HitObject; + [Resolved] + private TaikoHitObjectComposer? composer { get; set; } + public HitPlacementBlueprint() : base(new Hit()) { @@ -40,10 +44,12 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints return true; } - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { + var result = composer?.FindSnappedPositionAndTime(screenSpacePosition) ?? new SnapResult(screenSpacePosition, fallbackTime); piece.Position = ToLocalSpace(result.ScreenSpacePosition); - base.UpdateTimeAndPosition(result); + base.UpdateTimeAndPosition(result.ScreenSpacePosition, result.Time ?? fallbackTime); + return result; } } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index de3a4d96eb..3d5c95e1e8 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.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. -#nullable disable - using System; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Framework.Utils; @@ -26,12 +25,15 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints private readonly IHasDuration spanPlacementObject; + [Resolved] + private TaikoHitObjectComposer? composer { get; set; } + protected override bool IsValidForPlacement => Precision.DefinitelyBigger(spanPlacementObject.Duration, 0); public TaikoSpanPlacementBlueprint(HitObject hitObject) : base(hitObject) { - spanPlacementObject = hitObject as IHasDuration; + spanPlacementObject = (hitObject as IHasDuration)!; RelativeSizeAxes = Axes.Both; @@ -79,9 +81,11 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints EndPlacement(true); } - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { - base.UpdateTimeAndPosition(result); + var result = composer?.FindSnappedPositionAndTime(screenSpacePosition) ?? new SnapResult(screenSpacePosition, fallbackTime); + + base.UpdateTimeAndPosition(result.ScreenSpacePosition, result.Time ?? fallbackTime); if (PlacementActive == PlacementState.Active) { @@ -116,6 +120,8 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints originalPosition = ToLocalSpace(result.ScreenSpacePosition); } } + + return result; } } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoAbnormalDifficultySettings.cs b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoAbnormalDifficultySettings.cs index 38ba7b1b01..edf4d29e38 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoAbnormalDifficultySettings.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoAbnormalDifficultySettings.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Checks public override IEnumerable Run(BeatmapVerifierContext context) { - var diff = context.Beatmap.Difficulty; + var diff = context.CurrentDifficulty.Playable.Difficulty; Issue? issue; if (HasMoreThanOneDecimalPlace("Overall difficulty", diff.OverallDifficulty, out issue)) diff --git a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoInconsistentSkipBarLine.cs b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoInconsistentSkipBarLine.cs new file mode 100644 index 0000000000..621d336f85 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoInconsistentSkipBarLine.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Taiko.Edit.Checks +{ + public class CheckTaikoInconsistentSkipBarLine : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Timing, "Inconsistent \"Skip Bar Line\" setting", CheckScope.BeatmapSet); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateInconsistentOmitFirstBarLine(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (context.AllDifficulties.Count() <= 1) + yield break; + + // Inconsistent bar line omission only matters for osu!taiko difficulties, so only check those + var taikoBeatmaps = context.AllDifficulties.Where(b => b.Playable.BeatmapInfo.Ruleset.ShortName == "taiko").ToList(); + + if (taikoBeatmaps.Count <= 1) + yield break; + + var referenceBeatmap = context.CurrentDifficulty.Playable; + var referenceTimingPoints = referenceBeatmap.ControlPointInfo.TimingPoints; + + var otherTaikoBeatmaps = taikoBeatmaps.Where(b => b.Playable != referenceBeatmap).ToList(); + + foreach (var beatmap in otherTaikoBeatmaps) + { + var timingPoints = beatmap.Playable.ControlPointInfo.TimingPoints; + + foreach (var referencePoint in referenceTimingPoints) + { + var matchingPoint = TimingCheckUtils.FindExactMatchingTimingPoint(timingPoints, referencePoint.Time); + + if (matchingPoint == null) + // Inconsistent timing points - that's handled by `CheckInconsistentTimingControlPoints`, so skip + continue; + + if (referencePoint.OmitFirstBarLine != matchingPoint.OmitFirstBarLine) + { + yield return new IssueTemplateInconsistentOmitFirstBarLine(this).Create(referencePoint.Time, beatmap.Playable.BeatmapInfo.DifficultyName); + } + } + } + } + + public class IssueTemplateInconsistentOmitFirstBarLine : IssueTemplate + { + public IssueTemplateInconsistentOmitFirstBarLine(ICheck check) + : base(check, IssueType.Problem, "Inconsistent \"Skip Bar Line\" setting in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs new file mode 100644 index 0000000000..8ef911c18e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.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 System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; + +namespace osu.Game.Rulesets.Taiko.Edit.Checks +{ + public class CheckTaikoLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21taiko#general + yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Muzukashii"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Oni"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Inner Oni"); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs index 52f7176b3f..7f7da92688 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs @@ -60,6 +60,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup { Caption = EditorSetupStrings.BaseVelocity, HintText = EditorSetupStrings.BaseVelocityDescription, + KeyboardStep = 0.1f, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, @@ -74,6 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup { Caption = EditorSetupStrings.TickRate, HintText = EditorSetupStrings.TickRateDescription, + KeyboardStep = 1, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs index f5c3f1846d..737347a64c 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Rulesets.Taiko.Edit.Checks; @@ -13,7 +14,17 @@ namespace osu.Game.Rulesets.Taiko.Edit { private readonly List checks = new List { + // Compose + new CheckConcurrentObjects(), + + // Spread + new CheckTaikoLowestDiffDrainTime(), + + // Settings new CheckTaikoAbnormalDifficultySettings(), + + // Timing + new CheckTaikoInconsistentSkipBarLine(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs index 027723c02c..f0c3eec044 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.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.Collections.Generic; +using System.Linq; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Edit.Blueprints; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; namespace osu.Game.Rulesets.Taiko.Edit { public partial class TaikoBlueprintContainer : ComposeBlueprintContainer { - public TaikoBlueprintContainer(HitObjectComposer composer) + public new TaikoHitObjectComposer Composer => (TaikoHitObjectComposer)base.Composer; + + public TaikoBlueprintContainer(TaikoHitObjectComposer composer) : base(composer) { } @@ -19,5 +25,22 @@ namespace osu.Game.Rulesets.Taiko.Edit public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) => new TaikoSelectionBlueprint(hitObject); + + protected override bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints) + { + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // The final movement position, relative to movementBlueprintOriginalPosition. + Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled; + + // Retrieve a snapped position. + var result = Composer.FindSnappedPositionAndTime(movePosition); + + var referenceBlueprint = blueprints.First().blueprint; + bool moved = SelectionHandler.HandleMovement(new MoveSelectionEvent(referenceBlueprint, result.ScreenSpacePosition - referenceBlueprint.ScreenSpaceSelectionPoint)); + if (moved) + ApplySnapResultTime(result, referenceBlueprint.Item.StartTime); + return moved; + } } } diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs index d97a854ff7..54031f0c9f 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; @@ -12,6 +13,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Taiko.Edit { + [Cached] public partial class TaikoHitObjectComposer : ScrollingHitObjectComposer { protected override bool ApplyHorizontalCentering => false; diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index be2a5ac144..364324087b 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -62,10 +62,7 @@ namespace osu.Game.Rulesets.Taiko.Edit if (h is not TaikoStrongableHitObject strongable) return; if (strongable.IsStrong != state) - { strongable.IsStrong = state; - EditorBeatmap.Update(strongable); - } }); } @@ -77,10 +74,7 @@ namespace osu.Game.Rulesets.Taiko.Edit EditorBeatmap.PerformOnSelection(h => { if (h is Hit taikoHit) - { taikoHit.Type = state ? HitType.Rim : HitType.Centre; - EditorBeatmap.Update(h); - } }); } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModConstantSpeed.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModConstantSpeed.cs index 81973e65cc..e6fd5fc93e 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModConstantSpeed.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModConstantSpeed.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; using osu.Game.Rulesets.Mods; @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Mods public override string Acronym => "CS"; public override double ScoreMultiplier => 0.9; public override LocalisableString Description => "No more tricky speed changes!"; - public override IconUsage? Icon => FontAwesome.Solid.Equals; + public override IconUsage? Icon => OsuIcon.ModConstantSpeed; public override ModType Type => ModType.Conversion; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 99a064d35f..87d7aabf86 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; +using System.Collections.Generic; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods @@ -19,17 +21,33 @@ namespace osu.Game.Rulesets.Taiko.Mods ReadCurrentFromDifficulty = _ => 1, }; - public override string SettingDescription + public override string ExtendedIconInformation { get { - string scrollSpeed = ScrollSpeed.IsDefault ? string.Empty : $"Scroll x{ScrollSpeed.Value:N2}"; + if (!IsExactlyOneSettingChanged(ScrollSpeed, OverallDifficulty, DrainRate)) + return string.Empty; - return string.Join(", ", new[] - { - base.SettingDescription, - scrollSpeed - }.Where(s => !string.IsNullOrEmpty(s))); + if (!ScrollSpeed.IsDefault) return format("SC", ScrollSpeed, 2); + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty, 1); + if (!DrainRate.IsDefault) return format("HP", DrainRate, 1); + + return string.Empty; + + string format(string acronym, DifficultyBindable bindable, int digits) + => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(digits)}"; + } + } + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + foreach (var setting in base.SettingDescription) + yield return setting; + + if (!ScrollSpeed.IsDefault) + yield return ("Scroll speed", $"x{ScrollSpeed.Value:N2}"); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs index 009f2854f8..1bc9277210 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs @@ -19,6 +19,8 @@ namespace osu.Game.Rulesets.Taiko.Mods public override void ApplyToDifficulty(BeatmapDifficulty difficulty) { base.ApplyToDifficulty(difficulty); + + difficulty.OverallDifficulty *= ADJUST_RATIO; difficulty.SliderMultiplier *= slider_multiplier; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs index 64f2f4c18a..02c06850b7 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs @@ -47,28 +47,15 @@ namespace osu.Game.Rulesets.Taiko.Mods { this.taikoPlayfield = taikoPlayfield; - FlashlightSize = adjustSizeForPlayfieldAspectRatio(GetSize()); + FlashlightSize = new Vector2(0, GetSize()); FlashlightSmoothness = 1.4f; AddLayout(flashlightProperties); } - /// - /// Returns the aspect ratio-adjusted size of the flashlight. - /// This ensures that the size of the flashlight remains independent of taiko-specific aspect ratio adjustments. - /// - /// - /// The size of the flashlight. - /// The value provided here should always come from . - /// - private Vector2 adjustSizeForPlayfieldAspectRatio(float size) - { - return new Vector2(0, size * taikoPlayfield.Parent!.Scale.Y); - } - protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), adjustSizeForPlayfieldAspectRatio(size), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(0, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; @@ -82,7 +69,7 @@ namespace osu.Game.Rulesets.Taiko.Mods FlashlightPosition = ToLocalSpace(taikoPlayfield.HitTarget.ScreenSpaceDrawQuad.Centre); ClearTransforms(targetMember: nameof(FlashlightSize)); - FlashlightSize = adjustSizeForPlayfieldAspectRatio(GetSize()); + FlashlightSize = new Vector2(0, GetSize()); flashlightProperties.Validate(); } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs index ba41175461..8f01c21894 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -23,6 +24,8 @@ namespace osu.Game.Rulesets.Taiko.Mods public override void ApplyToDifficulty(BeatmapDifficulty difficulty) { base.ApplyToDifficulty(difficulty); + + difficulty.OverallDifficulty = Math.Min(difficulty.OverallDifficulty * ADJUST_RATIO, 10.0f); difficulty.SliderMultiplier *= slider_multiplier; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs index 2c3b4a8d18..8b6fb71d51 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Mods protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { - ApplyNormalVisibilityState(hitObject, state); + // intentional no-op } protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs new file mode 100644 index 0000000000..2132121cd2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModSimplifiedRhythm : Mod, IApplicableToBeatmap + { + public override string Name => "Simplified Rhythm"; + public override string Acronym => "SR"; + public override double ScoreMultiplier => 0.6; + public override LocalisableString Description => "Simplify tricky rhythms!"; + public override IconUsage? Icon => OsuIcon.ModSimplifiedRhythm; + public override ModType Type => ModType.DifficultyReduction; + + [SettingSource("1/3 to 1/2 conversion", "Converts 1/3 patterns to 1/2 rhythm.")] + public Bindable OneThirdConversion { get; } = new BindableBool(); + + [SettingSource("1/6 to 1/4 conversion", "Converts 1/6 patterns to 1/4 rhythm.")] + public Bindable OneSixthConversion { get; } = new BindableBool(true); + + [SettingSource("1/8 to 1/4 conversion", "Converts 1/8 patterns to 1/4 rhythm.")] + public Bindable OneEighthConversion { get; } = new BindableBool(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var taikoBeatmap = (TaikoBeatmap)beatmap; + var controlPointInfo = taikoBeatmap.ControlPointInfo; + + Hit[] hits = taikoBeatmap.HitObjects.OfType().ToArray(); + + if (hits.Length == 0) + return; + + var conversions = new List<(int, int)>(); + + if (OneEighthConversion.Value) conversions.Add((8, 4)); + if (OneSixthConversion.Value) conversions.Add((6, 4)); + if (OneThirdConversion.Value) conversions.Add((3, 2)); + + bool inPattern = false; + + foreach ((int baseRhythm, int adjustedRhythm) in conversions) + { + int patternStartIndex = 0; + + for (int i = 1; i < hits.Length; i++) + { + double snapValue = getSnapBetweenNotes(controlPointInfo, hits[i - 1], hits[i]); + + if (inPattern) + { + // pattern continues + if (snapValue == baseRhythm) + continue; + + inPattern = false; + processPattern(i); + } + else + { + if (snapValue == baseRhythm) + { + patternStartIndex = i - 1; + inPattern = true; + } + } + } + + // Process the last pattern if we reached the end of the beatmap and are still in a pattern. + if (inPattern) + processPattern(hits.Length); + + void processPattern(int patternEndIndex) + { + // Iterate through the pattern + for (int j = patternStartIndex; j < patternEndIndex; j++) + { + int indexInPattern = j - patternStartIndex; + + switch (baseRhythm) + { + // 1/8: Remove every second note + case 8: + { + if (indexInPattern % 2 == 1) + { + taikoBeatmap.HitObjects.Remove(hits[j]); + } + + break; + } + + // 1/6 and 1/3: Remove every second note and adjust time of every third + case 6: + case 3: + { + if (indexInPattern % 3 == 1) + taikoBeatmap.HitObjects.Remove(hits[j]); + else if (indexInPattern % 3 == 2) + hits[j].StartTime = hits[j - 2].StartTime + controlPointInfo.TimingPointAt(hits[j].StartTime).BeatLength / adjustedRhythm; + + break; + } + + default: + throw new ArgumentOutOfRangeException(nameof(baseRhythm)); + } + } + } + } + } + + private int getSnapBetweenNotes(ControlPointInfo controlPointInfo, Hit currentNote, Hit nextNote) + { + var currentTimingPoint = controlPointInfo.TimingPointAt(currentNote.StartTime); + return controlPointInfo.GetClosestBeatDivisor(currentTimingPoint.Time + (nextNote.StartTime - currentNote.StartTime)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs index 5e959387ec..4b6a9780a3 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs @@ -6,9 +6,11 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Beatmaps.Timing; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Objects; @@ -24,6 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override string Name => @"Single Tap"; public override string Acronym => @"SG"; + public override IconUsage? Icon => OsuIcon.ModSingleTap; public override LocalisableString Description => @"One key for dons, one key for kats."; public override bool Ranked => true; @@ -63,7 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Mods foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks) periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1)); - static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh); + static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Ok); } nonGameplayPeriods = new PeriodTracker(periods); diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs index fc3913f56d..f1feb8153a 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs @@ -3,8 +3,10 @@ using System; using System.Linq; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Objects; @@ -16,6 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Mods public override string Name => "Swap"; public override string Acronym => "SW"; public override LocalisableString Description => @"Dons become kats, kats become dons"; + public override IconUsage? Icon => OsuIcon.ModSwap; public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModRandom)).ToArray(); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 547d0afe4a..f4dc1f18bd 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -40,6 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private int rollingHits; private readonly Container tickContainer; + private SkinnableDrawable headPiece; private Color4 colourIdle; private Color4 colourEngaged; @@ -59,7 +60,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Content.Add(tickContainer = new Container { RelativeSizeAxes = Axes.Both, - Depth = float.MinValue + Depth = -1, }); } @@ -79,7 +80,13 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void RecreatePieces() { + if (headPiece != null) + Content.Remove(headPiece, true); + base.RecreatePieces(); + + Content.Add(headPiece = createHeadPiece()); + updateColour(); Height = HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE; } @@ -122,6 +129,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollBody), _ => new ElongatedCirclePiece()); + private SkinnableDrawable createHeadPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollHead), _ => Empty()) + { + RelativeSizeAxes = Axes.Y, + Depth = -2, + }; + public override bool OnPressed(KeyBindingPressEvent e) => false; private void onNewResult(DrawableHitObject obj, JudgementResult result) @@ -174,7 +187,23 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private void updateColour(double fadeDuration = 0) { Color4 newColour = Interpolation.ValueAt((float)rollingHits / rolling_hits_for_engaged_colour, colourIdle, colourEngaged, 0, 1); - (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); + + if (fadeDuration == 0) + { + // fade duration is 0 when calling via `RecreatePieces()`. + // in this case we want to apply the colour *without* using transforms. + // using transforms may result in the application of colour being undone via `DrawableHitObject.UpdateState()` clearing transforms. + if (MainPiece.Drawable is IHasAccentColour mainPieceWithAccentColour) + mainPieceWithAccentColour.AccentColour = newColour; + + if (headPiece.Drawable is IHasAccentColour headPieceWithAccentColour) + headPieceWithAccentColour.AccentColour = newColour; + } + else + { + (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); + (headPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); + } } public partial class StrongNestedHit : DrawableStrongNestedHit diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 28617b35f6..6ad14c87d1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -6,14 +6,9 @@ using System; using System.Linq; using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.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.Taiko.Skinning.Default; @@ -25,11 +20,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public partial class DrawableSwell : DrawableTaikoHitObject { - private const float target_ring_thick_border = 1.4f; - private const float target_ring_thin_border = 1f; - private const float target_ring_scale = 5f; - private const float inner_ring_alpha = 0.65f; - /// /// Offset away from the start time of the swell at which the ring starts appearing. /// @@ -38,9 +28,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private Vector2 baseSize; private readonly Container ticks; - private readonly Container bodyContainer; - private readonly CircularContainer targetRing; - private readonly CircularContainer expandingRing; private double? lastPressHandleTime; @@ -51,6 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// public bool MustAlternate { get; internal set; } = true; + public event Action UpdateHitProgress; + public DrawableSwell() : this(null) { @@ -61,87 +50,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { FillMode = FillMode.Fit; - Content.Add(bodyContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Depth = 1, - Children = new Drawable[] - { - expandingRing = new CircularContainer - { - Name = "Expanding ring", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0, - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - Masking = true, - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = inner_ring_alpha, - } - } - }, - targetRing = new CircularContainer - { - Name = "Target ring (thick border)", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = target_ring_thick_border, - Blending = BlendingParameters.Additive, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }, - new CircularContainer - { - Name = "Target ring (thin border)", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = target_ring_thin_border, - BorderColour = Color4.White, - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - } - } - } - } - }); - AddInternal(ticks = new Container { RelativeSizeAxes = Axes.Both }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - expandingRing.Colour = colours.YellowLight; - targetRing.BorderColour = colours.YellowDark.Opacity(0.25f); - } - protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Swell), - _ => new SwellCirclePiece + _ => new DefaultSwell { - // to allow for rotation transform Anchor = Anchor.Centre, Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, }); protected override void RecreatePieces() @@ -208,16 +125,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables int numHits = ticks.Count(r => r.IsHit); - float completion = (float)numHits / HitObject.RequiredHits; - - expandingRing - .FadeTo(expandingRing.Alpha + Math.Clamp(completion / 16, 0.1f, 0.6f), 50) - .Then() - .FadeTo(completion / 8, 2000, Easing.OutQuint); - - MainPiece.Drawable.RotateTo((float)(completion * HitObject.Duration / 8), 4000, Easing.OutQuint); - - expandingRing.ScaleTo(1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 260, Easing.OutQuint); + UpdateHitProgress?.Invoke(numHits); if (numHits == HitObject.RequiredHits) ApplyMaxResult(); @@ -248,28 +156,21 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } - protected override void UpdateStartTimeStateTransforms() - { - base.UpdateStartTimeStateTransforms(); - - using (BeginDelayedSequence(-ring_appear_offset)) - targetRing.ScaleTo(target_ring_scale, 400, Easing.OutQuint); - } - protected override void UpdateHitStateTransforms(ArmedState state) { - const double transition_duration = 300; + base.UpdateHitStateTransforms(state); switch (state) { case ArmedState.Idle: - expandingRing.FadeTo(0); break; case ArmedState.Miss: + this.Delay(300).FadeOut(); + break; + case ArmedState.Hit: - this.FadeOut(transition_duration, Easing.Out); - bodyContainer.ScaleTo(1.4f, transition_duration); + this.Delay(660).FadeOut(); break; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs index 04dd01e066..88554ba257 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs @@ -4,9 +4,7 @@ #nullable disable using JetBrains.Annotations; -using osu.Framework.Graphics; using osu.Framework.Input.Events; -using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables @@ -25,8 +23,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { } - protected override void UpdateInitialTransforms() => this.FadeOut(); - public void TriggerResult(bool hit) { HitObject.StartTime = Time.Current; @@ -43,7 +39,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public override bool OnPressed(KeyBindingPressEvent e) => false; - protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollTick), - _ => new TickPiece()); + protected override SkinnableDrawable CreateMainPiece() => null; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 0cf9651965..520ac2ba80 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -154,9 +154,13 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (MainPiece != null) Content.Remove(MainPiece, true); - Content.Add(MainPiece = CreateMainPiece()); + MainPiece = CreateMainPiece(); + + if (MainPiece != null) + Content.Add(MainPiece); } + [CanBeNull] protected abstract SkinnableDrawable CreateMainPiece(); } } diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs index a0a687dca6..6f10c03a96 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; @@ -42,5 +43,8 @@ namespace osu.Game.Rulesets.Taiko.Replays return new LegacyReplayFrame(Time, null, null, state); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is TaikoReplayFrame taikoFrame && Time == taikoFrame.Time && Actions.SequenceEqual(taikoFrame.Actions); } } diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs index b44ef8ee93..f3a478f592 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs @@ -1,18 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Scoring { public class TaikoHitWindows : HitWindows { - internal static readonly DifficultyRange[] TAIKO_RANGES = - { - new DifficultyRange(HitResult.Great, 50, 35, 20), - new DifficultyRange(HitResult.Ok, 120, 80, 50), - new DifficultyRange(HitResult.Miss, 135, 95, 70), - }; + public static readonly DifficultyRange GREAT_WINDOW_RANGE = new DifficultyRange(50, 35, 20); + public static readonly DifficultyRange OK_WINDOW_RANGE = new DifficultyRange(120, 80, 50); + public static readonly DifficultyRange MISS_WINDOW_RANGE = new DifficultyRange(135, 95, 70); + + private double great; + private double ok; + private double miss; public override bool IsHitResultAllowed(HitResult result) { @@ -27,6 +30,29 @@ namespace osu.Game.Rulesets.Taiko.Scoring return false; } - protected override DifficultyRange[] GetRanges() => TAIKO_RANGES; + public override void SetDifficulty(double difficulty) + { + great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE)) - 0.5; + ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE)) - 0.5; + miss = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(difficulty, MISS_WINDOW_RANGE)) - 0.5; + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Great: + return great; + + case HitResult.Ok: + return ok; + + case HitResult.Miss: + return miss; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs index cecb99c690..d94031380b 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio.Track; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Shapes; @@ -112,5 +113,13 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableHitObject.IsNotNull()) + drawableHitObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwell.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwell.cs new file mode 100644 index 0000000000..3b3684d219 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwell.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Taiko.Skinning.Default; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonSwell : DefaultSwell + { + protected override Drawable CreateCentreCircle() + { + return new ArgonSwellCirclePiece + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs index bfc9e8648d..b0a1c5d3f7 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Graphics; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Argon { @@ -18,6 +21,59 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon { switch (lookup) { + case GlobalSkinnableContainerLookup containerLookup: + // Only handle per ruleset defaults here. + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var leaderboard = container.OfType().FirstOrDefault(); + var comboCounter = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + if (leaderboard != null) + { + leaderboard.Anchor = leaderboard.Origin = Anchor.BottomLeft; + leaderboard.Position = new Vector2(36, -140); + leaderboard.Height = 140; + } + + if (comboCounter != null) + comboCounter.Position = new Vector2(36, -66); + + if (spectatorList != null) + { + spectatorList.Position = new Vector2(320, -280); + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.TopLeft; + } + }) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new DrawableGameplayLeaderboard(), + new ArgonComboCounter + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Scale = new Vector2(1.3f), + }, + new SpectatorList + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } + }, + }; + } + + return null; + case SkinComponentLookup resultComponent: // This should eventually be moved to a skin setting, when supported. if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great) @@ -69,7 +125,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon return new ArgonHitExplosion(taikoComponent.Component); case TaikoSkinComponents.Swell: - return new ArgonSwellCirclePiece(); + return new ArgonSwell(); } break; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs index 288ffde052..9b2b0c69a0 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs @@ -13,7 +13,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon public const int SAMPLE_VOLUME_THRESHOLD_MEDIUM = 60; public VolumeAwareHitSampleInfo(HitSampleInfo sampleInfo, bool isStrong = false) - : base(sampleInfo.Name, isStrong ? BANK_STRONG : getBank(sampleInfo.Bank, sampleInfo.Name, sampleInfo.Volume), sampleInfo.Suffix, sampleInfo.Volume) + : base(sampleInfo.Name, isStrong ? BANK_STRONG : getBank(sampleInfo.Bank, sampleInfo.Name, sampleInfo.Volume), sampleInfo.Suffix, sampleInfo.Volume, + sampleInfo.EditorAutoBank, sampleInfo.UseBeatmapSamples) { } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs index b3833d372c..a7b1b9c4b6 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -202,5 +203,13 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default .Then() .FadeEdgeEffectTo(edge_alpha_kiai, duration, Easing.OutQuint); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableHitObject.IsNotNull()) + drawableHitObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultSwell.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultSwell.cs new file mode 100644 index 0000000000..ac72ba73b8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultSwell.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 osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Default +{ + public partial class DefaultSwell : Container + { + private const float target_ring_thick_border = 1.4f; + private const float target_ring_thin_border = 1f; + private const float target_ring_scale = 5f; + private const float inner_ring_alpha = 0.65f; + + private DrawableSwell drawableSwell = null!; + + private readonly Container bodyContainer; + private readonly CircularContainer targetRing; + private readonly CircularContainer expandingRing; + private readonly Drawable centreCircle; + private int numHits; + + public DefaultSwell() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + + Content.Add(bodyContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Depth = 1, + Children = new[] + { + expandingRing = new CircularContainer + { + Name = "Expanding ring", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Masking = true, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = inner_ring_alpha, + } + } + }, + targetRing = new CircularContainer + { + Name = "Target ring (thick border)", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = target_ring_thick_border, + Blending = BlendingParameters.Additive, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new CircularContainer + { + Name = "Target ring (thin border)", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = target_ring_thin_border, + BorderColour = Color4.White, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + } + }, + centreCircle = CreateCentreCircle(), + } + }); + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject, OsuColour colours) + { + drawableSwell = (DrawableSwell)hitObject; + drawableSwell.UpdateHitProgress += animateSwellProgress; + drawableSwell.ApplyCustomUpdateState += updateStateTransforms; + + expandingRing.Colour = colours.YellowLight; + targetRing.BorderColour = colours.YellowDark.Opacity(0.25f); + } + + protected virtual Drawable CreateCentreCircle() + { + return new SwellCirclePiece + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + private void animateSwellProgress(int numHits) + { + this.numHits = numHits; + + float completion = (float)numHits / drawableSwell.HitObject.RequiredHits; + expandingRing.Alpha += Math.Clamp(completion / 16, 0.1f, 0.6f); + } + + protected override void Update() + { + base.Update(); + + float completion = (float)numHits / drawableSwell.HitObject.RequiredHits; + + centreCircle.Rotation = (float)Interpolation.DampContinuously(centreCircle.Rotation, + (float)(completion * drawableSwell.HitObject.Duration / 8), 500, Math.Abs(Time.Elapsed)); + expandingRing.Scale = new Vector2((float)Interpolation.DampContinuously(expandingRing.Scale.X, + 1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 35, Math.Abs(Time.Elapsed))); + expandingRing.Alpha = (float)Interpolation.DampContinuously(expandingRing.Alpha, completion / 16, 250, Math.Abs(Time.Elapsed)); + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSwell)) + return; + + Swell swell = drawableSwell.HitObject; + + using (BeginAbsoluteSequence(swell.StartTime)) + { + if (state == ArmedState.Idle) + expandingRing.FadeTo(0); + + const double ring_appear_offset = 100; + + targetRing.Delay(ring_appear_offset).ScaleTo(target_ring_scale, 400, Easing.OutQuint); + } + + using (BeginAbsoluteSequence(drawableSwell.HitStateUpdateTime)) + { + const double transition_duration = 300; + + bodyContainer.FadeOut(transition_duration, Easing.OutQuad); + bodyContainer.ScaleTo(1.4f, transition_duration); + centreCircle.FadeOut(transition_duration, Easing.OutQuad); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSwell.IsNotNull()) + { + drawableSwell.UpdateHitProgress -= animateSwellProgress; + drawableSwell.ApplyCustomUpdateState -= updateStateTransforms; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/SwellCirclePiece.cs similarity index 100% rename from osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs rename to osu.Game.Rulesets.Taiko/Skinning/Default/SwellCirclePiece.cs diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/TaikoTrianglesSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/TaikoTrianglesSkinTransformer.cs new file mode 100644 index 0000000000..f627417889 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/TaikoTrianglesSkinTransformer.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Default +{ + public class TaikoTrianglesSkinTransformer : SkinTransformer + { + public TaikoTrianglesSkinTransformer(ISkin skin) + : base(skin) + { + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + case GlobalSkinnableContainerLookup containerLookup: + { + // Only handle per ruleset defaults here. + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var leaderboard = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + if (leaderboard != null) + { + leaderboard.Position = new Vector2(40, -100); + leaderboard.Height = 180; + leaderboard.Anchor = Anchor.BottomLeft; + leaderboard.Origin = Anchor.BottomLeft; + } + + if (spectatorList != null) + { + spectatorList.HeaderFont.Value = Typeface.Venera; + spectatorList.HeaderColour.Value = new OsuColour().BlueLighter; + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.TopLeft; + spectatorList.Position = new Vector2(320, -280); + } + }) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new DrawableGameplayLeaderboard(), + new SpectatorList + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } + }, + }; + } + + return null; + } + } + + return base.GetDrawableComponent(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index 78be0ef643..34339b185d 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -21,14 +20,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { get { - // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii. - // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box. - var headCentre = headCircle.ScreenSpaceDrawQuad.Centre; + var headCentre = (body.ScreenSpaceDrawQuad.TopLeft + body.ScreenSpaceDrawQuad.BottomLeft) / 2; var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2; - float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2; - float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2; - float radius = Math.Max(headRadius, tailRadius); + float radius = body.ScreenSpaceDrawQuad.Height / 2; var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius); return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight); @@ -37,8 +32,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos); - private LegacyCirclePiece headCircle = null!; - private Sprite body = null!; private Sprite tailCircle = null!; @@ -66,10 +59,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy RelativeSizeAxes = Axes.Both, Texture = skin.GetTexture("taiko-roll-middle", WrapMode.ClampToEdge, WrapMode.ClampToEdge), }, - headCircle = new LegacyCirclePiece - { - RelativeSizeAxes = Axes.Y, - }, }; AccentColour = colours.YellowDark; @@ -101,7 +90,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { var colour = LegacyColourCompatibility.DisallowZeroAlpha(accentColour); - headCircle.AccentColour = colour; body.Colour = colour; tailCircle.Colour = colour; } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs index b9a432f3bd..b67648a3b8 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs @@ -60,11 +60,17 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { const double animation_time = 120; - (sprite as IFramedAnimation)?.GotoFrame(0); + var animation = sprite as IFramedAnimation; + + animation?.GotoFrame(0); (strongSprite as IFramedAnimation)?.GotoFrame(0); this.FadeInFromZero(animation_time).Then().FadeOut(animation_time * 1.5); + // legacy judgements don't play any transforms if they are an animation. + if (animation?.FrameCount > 1) + return; + this.ScaleTo(0.6f) .Then().ScaleTo(1.1f, animation_time * 0.8) .Then().ScaleTo(0.9f, animation_time * 0.4) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs index 9877efa127..58830f7492 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs @@ -17,12 +17,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { internal partial class LegacyKiaiGlow : BeatSyncedContainer { - private bool isKiaiActive; + [Resolved] + private HealthProcessor? healthProcessor { get; set; } + private bool isKiaiActive; private Sprite sprite = null!; - [BackgroundDependencyLoader(true)] - private void load(ISkinSource skin, HealthProcessor? healthProcessor) + [BackgroundDependencyLoader] + private void load(ISkinSource skin) { Child = sprite = new Sprite { @@ -33,6 +35,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy Scale = new Vector2(TaikoLegacyHitTarget.SCALE), Colour = new Colour4(255, 228, 0, 255), }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); if (healthProcessor != null) healthProcessor.NewJudgement += onNewJudgement; @@ -61,5 +68,13 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy sprite.ScaleTo(TaikoLegacyHitTarget.SCALE + 0.15f).Then() .ScaleTo(TaikoLegacyHitTarget.SCALE, 80, Easing.OutQuad); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (healthProcessor != null) + healthProcessor.NewJudgement -= onNewJudgement; + } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacySwell.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacySwell.cs new file mode 100644 index 0000000000..9f1b692984 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacySwell.cs @@ -0,0 +1,201 @@ +// Copyright (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.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Game.Skinning; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Audio; +using osuTK; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Framework.Extensions.ObjectExtensions; +using System; +using System.Globalization; +using osu.Framework.Utils; + +namespace osu.Game.Rulesets.Taiko.Skinning.Legacy +{ + public partial class LegacySwell : Container + { + private const float scale_adjust = 768f / 480; + private static readonly Vector2 swell_display_position = new Vector2(250f, 100f); + + private DrawableSwell drawableSwell = null!; + + private Container bodyContainer = null!; + private Sprite warning = null!; + private Sprite spinnerCircle = null!; + private Sprite approachCircle = null!; + private Sprite clearAnimation = null!; + private SkinnableSound clearSample = null!; + private LegacySpriteText remainingHitsText = null!; + + private bool samplePlayed; + + private int numHits; + + public LegacySwell() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject, ISkinSource skin) + { + Children = new Drawable[] + { + warning = new Sprite + { + Texture = skin.GetTexture("spinner-warning"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = skin.GetTexture("spinner-warning") != null ? Vector2.One : new Vector2(0.18f), + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Position = swell_display_position, // ballparked to be horizontally centred on 4:3 resolution + + Children = new Drawable[] + { + bodyContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + + Children = new Drawable[] + { + spinnerCircle = new Sprite + { + Texture = skin.GetTexture("spinner-circle"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.8f), + }, + approachCircle = new Sprite + { + Texture = skin.GetTexture("spinner-approachcircle"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.86f * 0.8f), + Alpha = 0.8f, + }, + remainingHitsText = new LegacySpriteText(LegacyFont.Score) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Position = new Vector2(0f, 130f), + Scale = Vector2.One, + }, + } + }, + clearAnimation = new Sprite + { + Texture = skin.GetTexture("spinner-osu"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Y = -40, + }, + }, + }, + clearSample = new SkinnableSound(new SampleInfo("spinner-osu")), + }; + + drawableSwell = (DrawableSwell)hitObject; + drawableSwell.UpdateHitProgress += animateSwellProgress; + drawableSwell.ApplyCustomUpdateState += updateStateTransforms; + } + + private void animateSwellProgress(int numHits) + { + this.numHits = numHits; + remainingHitsText.Text = (drawableSwell.HitObject.RequiredHits - numHits).ToString(CultureInfo.InvariantCulture); + spinnerCircle.Scale = new Vector2(Math.Min(0.94f, spinnerCircle.Scale.X + 0.02f)); + } + + protected override void Update() + { + base.Update(); + + int requiredHits = drawableSwell.HitObject.RequiredHits; + int remainingHits = requiredHits - numHits; + remainingHitsText.Scale = new Vector2((float)Interpolation.DampContinuously( + remainingHitsText.Scale.X, 1.6f - (0.6f * ((float)remainingHits / requiredHits)), 17.5, Math.Abs(Time.Elapsed))); + + spinnerCircle.Rotation = (float)Interpolation.DampContinuously(spinnerCircle.Rotation, 180f * numHits, 130, Math.Abs(Time.Elapsed)); + spinnerCircle.Scale = new Vector2((float)Interpolation.DampContinuously( + spinnerCircle.Scale.X, 0.8f, 120, Math.Abs(Time.Elapsed))); + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSwell)) + return; + + Swell swell = drawableSwell.HitObject; + + using (BeginAbsoluteSequence(swell.StartTime)) + { + if (state == ArmedState.Idle) + { + remainingHitsText.Text = $"{swell.RequiredHits}"; + samplePlayed = false; + } + + const double body_transition_duration = 200; + + warning.MoveTo(swell_display_position, body_transition_duration) + .ScaleTo(3, body_transition_duration, Easing.Out) + .FadeOut(body_transition_duration); + + bodyContainer.FadeIn(body_transition_duration); + approachCircle.ResizeTo(0.1f * 0.8f, swell.Duration); + } + + using (BeginAbsoluteSequence(drawableSwell.HitStateUpdateTime)) + { + const double clear_transition_duration = 300; + const double clear_fade_in = 120; + + bodyContainer.FadeOut(clear_transition_duration, Easing.OutQuad); + spinnerCircle.ScaleTo(spinnerCircle.Scale.X + 0.05f, clear_transition_duration, Easing.OutQuad); + + if (state == ArmedState.Hit) + { + if (!samplePlayed) + { + clearSample.Play(); + samplePlayed = true; + } + + clearAnimation + .MoveToOffset(new Vector2(0, -90 * scale_adjust), clear_fade_in * 2, Easing.Out) + .ScaleTo(0.4f) + .ScaleTo(1f, clear_fade_in * 2, Easing.Out) + .FadeIn() + .Delay(clear_fade_in * 3) + .FadeOut(clear_fade_in * 2.5); + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSwell.IsNotNull()) + { + drawableSwell.UpdateHitProgress -= animateSwellProgress; + drawableSwell.ApplyCustomUpdateState -= updateStateTransforms; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index 5bdb824f1c..73d32a7933 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Game.Audio; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { @@ -29,117 +32,186 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (lookup is SkinComponentLookup) + switch (lookup) { - // if a taiko skin is providing explosion sprites, hide the judgements completely - if (hasExplosion.Value) - return Drawable.Empty().With(d => d.Expire()); - } - - if (lookup is TaikoSkinComponentLookup taikoComponent) - { - switch (taikoComponent.Component) + case GlobalSkinnableContainerLookup containerLookup: { - case TaikoSkinComponents.DrumRollBody: - if (GetTexture("taiko-roll-middle") != null) - return new LegacyDrumRoll(); + // Modifications for global components. + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + // we don't have enough assets to display these components (this is especially the case on a "beatmap" skin). + if (!IsProvidingLegacyResources) return null; - case TaikoSkinComponents.InputDrum: - if (hasBarLeft) - return new LegacyInputDrum(); + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var combo = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + var leaderboard = container.OfType().FirstOrDefault(); - return null; + Vector2 pos = new Vector2(); - case TaikoSkinComponents.DrumSamplePlayer: - return null; + if (combo != null) + { + combo.Anchor = Anchor.BottomLeft; + combo.Origin = Anchor.BottomLeft; + combo.Scale = new Vector2(1.28f); - case TaikoSkinComponents.CentreHit: - case TaikoSkinComponents.RimHit: - if (hasHitCircle) - return new LegacyHit(taikoComponent.Component); + pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X); + } - return null; + if (leaderboard != null) + { + leaderboard.Anchor = Anchor.BottomLeft; + leaderboard.Origin = Anchor.BottomLeft; + leaderboard.Position = pos; + leaderboard.Height = 170; + pos += new Vector2(10 + leaderboard.Width, -leaderboard.Height); + } - case TaikoSkinComponents.DrumRollTick: - return this.GetAnimation("sliderscorepoint", false, false); + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.TopLeft; + spectatorList.Position = pos; + } + }) + { + new LegacyDefaultComboCounter(), + new SpectatorList(), + new DrawableGameplayLeaderboard(), + }; + } - case TaikoSkinComponents.Swell: - // todo: support taiko legacy swell (https://github.com/ppy/osu/issues/13601). - return null; + return null; + } - case TaikoSkinComponents.HitTarget: - if (GetTexture("taikobigcircle") != null) - return new TaikoLegacyHitTarget(); + case SkinComponentLookup: + { + // if a taiko skin is providing explosion sprites, hide the judgements completely + if (hasExplosion.Value) + return Drawable.Empty().With(d => d.Expire()); - return null; + break; + } - case TaikoSkinComponents.PlayfieldBackgroundRight: - if (GetTexture("taiko-bar-right") != null) - return new TaikoLegacyPlayfieldBackgroundRight(); + case TaikoSkinComponentLookup taikoComponent: + { + switch (taikoComponent.Component) + { + case TaikoSkinComponents.DrumRollHead: + if (GetTexture("taiko-roll-middle") != null) + return new LegacyCirclePiece(); - return null; + return null; - case TaikoSkinComponents.PlayfieldBackgroundLeft: - // This is displayed inside LegacyInputDrum. It is required to be there for layout purposes (can be seen on legacy skins). - if (GetTexture("taiko-bar-right") != null) - return Drawable.Empty(); + case TaikoSkinComponents.DrumRollBody: + if (GetTexture("taiko-roll-middle") != null) + return new LegacyDrumRoll(); - return null; + return null; - case TaikoSkinComponents.BarLine: - if (GetTexture("taiko-barline") != null) - return new LegacyBarLine(); + case TaikoSkinComponents.InputDrum: + if (hasBarLeft) + return new LegacyInputDrum(); - return null; + return null; - case TaikoSkinComponents.TaikoExplosionMiss: - var missSprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); - if (missSprite != null) - return new LegacyHitExplosion(missSprite); + case TaikoSkinComponents.DrumSamplePlayer: + return null; - return null; + case TaikoSkinComponents.CentreHit: + case TaikoSkinComponents.RimHit: + if (hasHitCircle) + return new LegacyHit(taikoComponent.Component); - case TaikoSkinComponents.TaikoExplosionOk: - case TaikoSkinComponents.TaikoExplosionGreat: - string hitName = getHitName(taikoComponent.Component); - var hitSprite = this.GetAnimation(hitName, true, false); + return null; - if (hitSprite != null) - { - var strongHitSprite = this.GetAnimation($"{hitName}k", true, false); + case TaikoSkinComponents.DrumRollTick: + return this.GetAnimation("sliderscorepoint", false, false); - return new LegacyHitExplosion(hitSprite, strongHitSprite); - } + case TaikoSkinComponents.Swell: + if (GetTexture("spinner-circle") != null) + return new LegacySwell(); - return null; + return null; - case TaikoSkinComponents.TaikoExplosionKiai: - // suppress the default kiai explosion if the skin brings its own sprites. - // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield. - if (hasExplosion.Value) - return Drawable.Empty().With(d => d.Expire()); + case TaikoSkinComponents.HitTarget: + if (GetTexture("taikobigcircle") != null) + return new TaikoLegacyHitTarget(); - return null; + return null; - case TaikoSkinComponents.Scroller: - if (GetTexture("taiko-slider") != null) - return new LegacyTaikoScroller(); + case TaikoSkinComponents.PlayfieldBackgroundRight: + if (GetTexture("taiko-bar-right") != null) + return new TaikoLegacyPlayfieldBackgroundRight(); - return null; + return null; - case TaikoSkinComponents.Mascot: - return new DrawableTaikoMascot(); + case TaikoSkinComponents.PlayfieldBackgroundLeft: + // This is displayed inside LegacyInputDrum. It is required to be there for layout purposes (can be seen on legacy skins). + if (GetTexture("taiko-bar-right") != null) + return Drawable.Empty(); - case TaikoSkinComponents.KiaiGlow: - if (GetTexture("taiko-glow") != null) - return new LegacyKiaiGlow(); + return null; - return null; + case TaikoSkinComponents.BarLine: + if (GetTexture("taiko-barline") != null) + return new LegacyBarLine(); - default: - throw new UnsupportedSkinComponentException(lookup); + return null; + + case TaikoSkinComponents.TaikoExplosionMiss: + var missSprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); + if (missSprite != null) + return new LegacyHitExplosion(missSprite); + + return null; + + case TaikoSkinComponents.TaikoExplosionOk: + case TaikoSkinComponents.TaikoExplosionGreat: + string hitName = getHitName(taikoComponent.Component); + var hitSprite = this.GetAnimation(hitName, true, false); + + if (hitSprite != null) + { + var strongHitSprite = this.GetAnimation($"{hitName}k", true, false); + + return new LegacyHitExplosion(hitSprite, strongHitSprite); + } + + return null; + + case TaikoSkinComponents.TaikoExplosionKiai: + // suppress the default kiai explosion if the skin brings its own sprites. + // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield. + if (hasExplosion.Value) + return Drawable.Empty().With(d => d.Expire()); + + return null; + + case TaikoSkinComponents.Scroller: + if (GetTexture("taiko-slider") != null) + return new LegacyTaikoScroller(); + + return null; + + case TaikoSkinComponents.Mascot: + return new DrawableTaikoMascot(); + + case TaikoSkinComponents.KiaiGlow: + if (GetTexture("taiko-glow") != null) + return new LegacyKiaiGlow(); + + return null; + + default: + throw new UnsupportedSkinComponentException(lookup); + } } } @@ -174,7 +246,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy private class LegacyTaikoSampleInfo : HitSampleInfo { public LegacyTaikoSampleInfo(HitSampleInfo sampleInfo) - : base(sampleInfo.Name, sampleInfo.Bank, sampleInfo.Suffix, sampleInfo.Volume) + : base(sampleInfo.Name, sampleInfo.Bank, sampleInfo.Suffix, sampleInfo.Volume, sampleInfo.EditorAutoBank, sampleInfo.UseBeatmapSamples) { } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 70e429a344..b25672f719 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; @@ -33,10 +34,13 @@ using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; using osu.Game.Rulesets.Configuration; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Rulesets.Taiko.Configuration; using osu.Game.Rulesets.Taiko.Edit.Setup; +using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Screens.Edit.Setup; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko { @@ -57,6 +61,9 @@ namespace osu.Game.Rulesets.Taiko case ArgonSkin: return new TaikoArgonSkinTransformer(skin); + case TrianglesSkin: + return new TaikoTrianglesSkinTransformer(skin); + case LegacySkin: return new TaikoLegacySkinTransformer(skin); } @@ -130,6 +137,7 @@ namespace osu.Game.Rulesets.Taiko new TaikoModEasy(), new TaikoModNoFail(), new MultiMod(new TaikoModHalfTime(), new TaikoModDaycore()), + new TaikoModSimplifiedRhythm(), }; case ModType.DifficultyIncrease: @@ -265,16 +273,57 @@ namespace osu.Game.Rulesets.Taiko } /// - public override BeatmapDifficulty GetRateAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, double rate) + public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) { - BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty); + BeatmapDifficulty adjustedDifficulty = base.GetAdjustedDisplayDifficulty(beatmapInfo, mods); + double rate = ModUtils.CalculateRateWithMods(mods); - var greatHitWindowRange = TaikoHitWindows.TAIKO_RANGES.Single(range => range.Result == HitResult.Great); - double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, TaikoHitWindows.GREAT_WINDOW_RANGE); greatHitWindow /= rate; - adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max); + adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, TaikoHitWindows.GREAT_WINDOW_RANGE); return adjustedDifficulty; } + + public override IEnumerable GetBeatmapAttributesForDisplay(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) + { + var originalDifficulty = beatmapInfo.Difficulty; + // `modAdjustedDifficulty` contains only the direct effect of mods. + // `effectiveDifficulty` contains the "perceived" effect of rate-adjusting mods on OD and AR. + // we make a distinction here, because some of the calculations below will require very careful maneuvering between the two for correct results. + var modAdjustedDifficulty = base.GetAdjustedDisplayDifficulty(beatmapInfo, mods); + var effectiveDifficulty = GetAdjustedDisplayDifficulty(beatmapInfo, mods); + var colours = new OsuColour(); + + // when displaying hit window ranges with rate-changing mods active, we will want to adjust for rate ourselves, as `effectiveDifficulty` may not be accurate + // because `TaikoHitWindows` applies a floor-and-round operation that will result in inaccurate results + // (the floor-and-round needs to happen *before* rate is taken into account, not after). + var hitWindows = new TaikoHitWindows(); + hitWindows.SetDifficulty(modAdjustedDifficulty.OverallDifficulty); + double rate = ModUtils.CalculateRateWithMods(mods); + yield return new RulesetBeatmapAttribute(SongSelectStrings.Accuracy, @"OD", originalDifficulty.OverallDifficulty, effectiveDifficulty.OverallDifficulty, 10) + { + Description = "Affects timing requirements for hits and mash rate requirements for swells.", + AdditionalMetrics = hitWindows.GetAllAvailableWindows() + .Reverse() + .Select(window => new RulesetBeatmapAttribute.AdditionalMetric( + $"{window.result.GetDescription().ToUpperInvariant()} hit window", + LocalisableString.Interpolate($@"±{hitWindows.WindowFor(window.result) / rate:0.##} ms"), + colours.ForHitResult(window.result) + )) + .Append(new RulesetBeatmapAttribute.AdditionalMetric("Hits per second required to clear swells", LocalisableString.Interpolate($@"{TaikoBeatmapConverter.RequiredSwellHitsPerSecond(modAdjustedDifficulty.OverallDifficulty):0.#}"))) + .ToArray() + }; + + yield return new RulesetBeatmapAttribute(SongSelectStrings.HPDrain, @"HP", originalDifficulty.DrainRate, effectiveDifficulty.DrainRate, 10) + { + Description = "Affects the harshness of health drain and the health penalties for missing." + }; + + yield return new RulesetBeatmapAttribute(SongSelectStrings.ScrollSpeed, @"SS", 1f, (float)(effectiveDifficulty.SliderMultiplier / originalDifficulty.SliderMultiplier), 4) + { + Description = "Multiplier applied to the baseline scroll speed of the playfield when no mods are active." + }; + } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs index 28133ffcb2..31342b30c4 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs @@ -8,6 +8,7 @@ namespace osu.Game.Rulesets.Taiko InputDrum, CentreHit, RimHit, + DrumRollHead, DrumRollBody, DrumRollTick, Swell, diff --git a/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs b/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs index 0b7f6f621a..53d129e7ca 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs @@ -59,11 +59,10 @@ namespace osu.Game.Rulesets.Taiko.UI { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.X, - Height = 350, + RelativeSizeAxes = Axes.Both, + Height = 0.45f, Y = 20, Masking = true, - FillMode = FillMode.Fit, Children = new Drawable[] { mainContent = new Container diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs index c67f61052c..9f821ee93d 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Taiko.UI // Limit the maximum relative height of the playfield to one-third of available area to avoid it masking out on extreme resolutions. relativeHeight = Math.Min(relativeHeight, 1f / 3f); - Scale = new Vector2(Math.Max((Parent!.ChildSize.Y / 768f) * (relativeHeight / base_relative_height), 1f)); + Scale = new Vector2(Parent!.ChildSize.Y / 768f * (relativeHeight / base_relative_height)); Width = 1 / Scale.X; } diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index b02425eadd..a8fc9536b9 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -28,6 +28,7 @@ + diff --git a/osu.Game.Tests.iOS/AppDelegate.cs b/osu.Game.Tests.iOS/AppDelegate.cs new file mode 100644 index 0000000000..bfad59de43 --- /dev/null +++ b/osu.Game.Tests.iOS/AppDelegate.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 Foundation; +using osu.Framework.iOS; + +namespace osu.Game.Tests.iOS +{ + [Register("AppDelegate")] + public class AppDelegate : GameApplicationDelegate + { + protected override Framework.Game CreateGame() => new OsuTestBrowser(); + } +} diff --git a/osu.Game.Tests.iOS/Application.cs b/osu.Game.Tests.iOS/Program.cs similarity index 69% rename from osu.Game.Tests.iOS/Application.cs rename to osu.Game.Tests.iOS/Program.cs index e5df79f3de..35a90d7213 100644 --- a/osu.Game.Tests.iOS/Application.cs +++ b/osu.Game.Tests.iOS/Program.cs @@ -1,15 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.iOS; +using UIKit; namespace osu.Game.Tests.iOS { - public static class Application + public static class Program { public static void Main(string[] args) { - GameApplication.Main(new OsuTestBrowser()); + UIApplication.Main(args, null, typeof(AppDelegate)); } } } diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index e4b9d2ba94..9f13b0587b 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -1,4 +1,5 @@  + Exe net8.0-ios @@ -6,11 +7,19 @@ osu.Game.Tests osu.Game.Tests.iOS - + + $(NoWarn);CA2007 + %(RecursiveDir)%(Filename)%(Extension) + + + %(RecursiveDir)%(Filename)%(Extension) + iOS\%(RecursiveDir)%(Filename)%(Extension) + @@ -20,6 +29,7 @@ + diff --git a/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs b/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs new file mode 100644 index 0000000000..1dda2e314d --- /dev/null +++ b/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Tests.Beatmaps +{ + public class BeatmapExtensionsTest + { + [Test] + public void TestLengthCalculations() + { + var beatmap = new Beatmap + { + HitObjects = + { + new HitCircle { StartTime = 5_000 }, + new HitCircle { StartTime = 300_000 }, + new Spinner { StartTime = 280_000, Duration = 40_000 } + }, + Breaks = + { + new BreakPeriod(50_000, 75_000), + new BreakPeriod(100_000, 150_000), + } + }; + + Assert.That(beatmap.CalculatePlayableBounds(), Is.EqualTo((5_000, 320_000))); + Assert.That(beatmap.CalculatePlayableLength(), Is.EqualTo(315_000)); // 320_000 - 5_000 + Assert.That(beatmap.CalculateDrainLength(), Is.EqualTo(240_000)); // 315_000 - (25_000 + 50_000) = 315_000 - 75_000 + } + + [Test] + public void TestDrainLengthCannotGoNegative() + { + var beatmap = new Beatmap + { + HitObjects = + { + new HitCircle { StartTime = 5_000 }, + new HitCircle { StartTime = 300_000 }, + new Spinner { StartTime = 280_000, Duration = 40_000 } + }, + Breaks = + { + new BreakPeriod(0, 350_000), + } + }; + + Assert.That(beatmap.CalculatePlayableBounds(), Is.EqualTo((5_000, 320_000))); + Assert.That(beatmap.CalculatePlayableLength(), Is.EqualTo(315_000)); // 320_000 - 5_000 + Assert.That(beatmap.CalculateDrainLength(), Is.EqualTo(0)); // break period encompasses entire beatmap + } + } +} diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index adb1755c11..916e1e757a 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -42,9 +42,9 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = Decoder.GetDecoder(stream); var working = new TestWorkingBeatmap(decoder.Decode(stream)); - Assert.AreEqual(6, working.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(6, working.Beatmap.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapInfo.BeatmapVersion); + Assert.AreEqual(6, working.Beatmap.BeatmapVersion); + Assert.That(working.Beatmap.BeatmapInfo.Ruleset.Name, Is.Not.EqualTo("null placeholder ruleset")); + Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); } } @@ -59,9 +59,8 @@ namespace osu.Game.Tests.Beatmaps.Formats ((LegacyBeatmapDecoder)decoder).ApplyOffsets = applyOffsets; var working = new TestWorkingBeatmap(decoder.Decode(stream)); - Assert.AreEqual(4, working.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(4, working.Beatmap.BeatmapInfo.BeatmapVersion); - Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapInfo.BeatmapVersion); + Assert.AreEqual(4, working.Beatmap.BeatmapVersion); + Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion); Assert.AreEqual(-1, working.BeatmapInfo.Metadata.PreviewTime); } @@ -404,6 +403,35 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestComboColourCountIsLimitedToEight() + { + var decoder = new LegacySkinDecoder(); + + using (var resStream = TestResources.OpenResource("too-many-combo-colours.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var comboColors = decoder.Decode(stream).ComboColours; + + Debug.Assert(comboColors != null); + + Color4[] expectedColors = + { + new Color4(142, 199, 255, 255), + new Color4(255, 128, 128, 255), + new Color4(128, 255, 255, 255), + new Color4(128, 255, 128, 255), + new Color4(255, 187, 255, 255), + new Color4(255, 177, 140, 255), + new Color4(100, 100, 100, 255), + new Color4(142, 199, 255, 255), + }; + Assert.AreEqual(expectedColors.Length, comboColors.Count); + for (int i = 0; i < expectedColors.Length; i++) + Assert.AreEqual(expectedColors[i], comboColors[i]); + } + } + [Test] public void TestGetLastObjectTime() { diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index c8a09786ec..35ce733895 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -11,6 +11,7 @@ using NUnit.Framework; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; @@ -28,6 +29,7 @@ using osu.Game.Skinning; using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; +using osuTK.Graphics; namespace osu.Game.Tests.Beatmaps.Formats { @@ -184,6 +186,82 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedSlider.Path.ControlPoints.Count, Is.EqualTo(5)); } + [Test] + public void TestOnlyEightComboColoursEncoded() + { + var beatmapSkin = new LegacyBeatmapSkin(new BeatmapInfo(), null) + { + Configuration = + { + CustomComboColours = + { + new Color4(1, 1, 1, 255), + new Color4(2, 2, 2, 255), + new Color4(3, 3, 3, 255), + new Color4(4, 4, 4, 255), + new Color4(5, 5, 5, 255), + new Color4(6, 6, 6, 255), + new Color4(7, 7, 7, 255), + new Color4(8, 8, 8, 255), + new Color4(9, 9, 9, 255), + } + } + }; + + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy((new Beatmap(), beatmapSkin)), string.Empty); + Assert.That(decodedAfterEncode.skin.Configuration.CustomComboColours, Has.Count.EqualTo(8)); + } + + [Test] + public void TestEncodeStabilityOfSliderWithFractionalCoordinates() + { + Slider originalSlider = new Slider + { + Position = new Vector2(0.6f), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PERFECT_CURVE), + new PathControlPoint(new Vector2(25.6f, 78.4f)), + new PathControlPoint(new Vector2(55.8f, 34.2f)), + }) + }; + var beatmap = new Beatmap + { + HitObjects = { originalSlider } + }; + + var encoded = encodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty))); + var decodedAfterEncode = decodeFromLegacy(encoded, string.Empty, version: LegacyBeatmapEncoder.FIRST_LAZER_VERSION); + var decodedSlider = (Slider)decodedAfterEncode.beatmap.HitObjects[0]; + Assert.That(decodedSlider.Path.ControlPoints.Select(p => p.Position), + Is.EquivalentTo(originalSlider.Path.ControlPoints.Select(p => p.Position))); + } + + [Test] + public void TestEncodeCustomSampleBanks() + { + var beatmap = new Beatmap + { + HitObjects = + { + new HitCircle { StartTime = 100, Samples = [new HitSampleInfo(HitSampleInfo.HIT_NORMAL)] }, + new HitCircle { StartTime = 200, Samples = [new HitSampleInfo(HitSampleInfo.HIT_NORMAL, useBeatmapSamples: true)] }, + new HitCircle { StartTime = 300, Samples = [new HitSampleInfo(HitSampleInfo.HIT_NORMAL, suffix: "3", useBeatmapSamples: true)] }, + } + }; + + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty))), string.Empty); + + Assert.That(decodedAfterEncode.beatmap.HitObjects[0].Samples[0].Suffix, Is.Null); + Assert.That(decodedAfterEncode.beatmap.HitObjects[0].Samples[0].UseBeatmapSamples, Is.False); + + Assert.That(decodedAfterEncode.beatmap.HitObjects[1].Samples[0].Suffix, Is.Null); + Assert.That(decodedAfterEncode.beatmap.HitObjects[1].Samples[0].UseBeatmapSamples, Is.True); + + Assert.That(decodedAfterEncode.beatmap.HitObjects[2].Samples[0].Suffix, Is.EqualTo("3")); + Assert.That(decodedAfterEncode.beatmap.HitObjects[2].Samples[0].UseBeatmapSamples, Is.True); + } + private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b) { // equal to null, no need to SequenceEqual @@ -206,12 +284,14 @@ namespace osu.Game.Tests.Beatmaps.Formats } } - private (IBeatmap beatmap, TestLegacySkin skin) decodeFromLegacy(Stream stream, string name) + private (IBeatmap beatmap, TestLegacySkin skin) decodeFromLegacy(Stream stream, string name, int version = LegacyDecoder.LATEST_VERSION) { using (var reader = new LineBufferedReader(stream)) { - var beatmap = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(reader); + var beatmap = new LegacyBeatmapDecoder(version) { ApplyOffsets = false }.Decode(reader); var beatmapSkin = new TestLegacySkin(beatmaps_resource_store, name); + stream.Seek(0, SeekOrigin.Begin); + beatmapSkin.Configuration = new LegacySkinDecoder().Decode(reader); return (convert(beatmap), beatmapSkin); } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 713f2f3fb1..2815c9cd8f 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -13,6 +13,7 @@ using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; +using osu.Game.Extensions; using osu.Game.IO.Legacy; using osu.Game.Online.API.Requests.Responses; using osu.Game.Replays; @@ -155,10 +156,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); var beatmap = new TestBeatmap(ruleset) { - BeatmapInfo = - { - BeatmapVersion = beatmapVersion - } + BeatmapVersion = beatmapVersion }; var score = new Score @@ -324,6 +322,7 @@ namespace osu.Game.Tests.Beatmaps.Formats CountryCode = CountryCode.PL }; scoreInfo.ClientVersion = "2023.1221.0"; + scoreInfo.Pauses.AddRange([111111, 222222, 333333]); var beatmap = new TestBeatmap(ruleset); var score = new Score @@ -348,6 +347,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods)); Assert.That(decodedAfterEncode.ScoreInfo.ClientVersion, Is.EqualTo("2023.1221.0")); Assert.That(decodedAfterEncode.ScoreInfo.RealmUser.OnlineID, Is.EqualTo(3035836)); + Assert.That(decodedAfterEncode.ScoreInfo.Pauses, Is.EquivalentTo(new[] { 111111, 222222, 333333 })); }); } @@ -633,14 +633,14 @@ namespace osu.Game.Tests.Beatmaps.Formats MD5Hash = md5Hash, Ruleset = new OsuRuleset().RulesetInfo, Difficulty = new BeatmapDifficulty(), - BeatmapVersion = beatmapVersion, }, - // needs to have at least one objects so that `StandardisedScoreMigrationTools` doesn't die + // needs to have at least one object so that `StandardisedScoreMigrationTools` doesn't die // when trying to recompute total score. HitObjects = { new HitCircle() - } + }, + BeatmapVersion = beatmapVersion, }); } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index 647c0aed75..b10cce6a52 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs @@ -135,6 +135,24 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestNoopFadeTransformIsIgnoredForLifetime() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("noop-fade-transform-is-ignored-for-lifetime.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); + Assert.AreEqual(2, background.Elements.Count); + + Assert.AreEqual(1500, background.Elements[0].StartTime); + Assert.AreEqual(1500, background.Elements[1].StartTime); + } + } + [Test] public void TestOutOfOrderStartTimes() { @@ -288,6 +306,29 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestVideoWithCustomFadeIn() + { + var decoder = new LegacyStoryboardDecoder(); + + using var resStream = TestResources.OpenResource("video-custom-alpha-transform.osb"); + using var stream = new LineBufferedReader(resStream); + + var storyboard = decoder.Decode(stream); + + Assert.Multiple(() => + { + Assert.That(storyboard.GetLayer(@"Video").Elements, Has.Count.EqualTo(1)); + Assert.That(storyboard.GetLayer(@"Video").Elements.Single(), Is.InstanceOf()); + Assert.That(storyboard.GetLayer(@"Video").Elements.Single().StartTime, Is.EqualTo(-5678)); + Assert.That(((StoryboardVideo)storyboard.GetLayer(@"Video").Elements.Single()).Commands.Alpha.Single().StartTime, Is.EqualTo(1500)); + Assert.That(((StoryboardVideo)storyboard.GetLayer(@"Video").Elements.Single()).Commands.Alpha.Single().EndTime, Is.EqualTo(1600)); + + Assert.That(storyboard.EarliestEventTime, Is.Null); + Assert.That(storyboard.LatestEventTime, Is.Null); + }); + } + [Test] public void TestVideoAndBackgroundEventsDoNotAffectStoryboardBounds() { diff --git a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs index 8a95d26782..e1c385097f 100644 --- a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO.Archives; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; using MemoryStream = System.IO.MemoryStream; @@ -50,6 +51,45 @@ namespace osu.Game.Tests.Beatmaps.IO AddAssert("hit object is snapped", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28519).Within(0.001)); } + [Test] + public void TestFractionalObjectCoordinatesRounded() + { + IWorkingBeatmap beatmap = null!; + MemoryStream outStream = null!; + + // Ensure importer encoding is correct + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"fractional-coordinates.olz")); + AddAssert("second slider has fractional position", + () => ((IHasXPosition)beatmap.Beatmap.HitObjects[1]).X, + () => Is.EqualTo(-3.0517578E-05).Within(0.00001)); + AddAssert("second slider path has fractional coordinates", + () => ((IHasPath)beatmap.Beatmap.HitObjects[1]).Path.ControlPoints[1].Position.X, + () => Is.EqualTo(191.999939).Within(0.00001)); + AddAssert("second hit circle has fractional position", + () => ((IHasYPosition)beatmap.Beatmap.HitObjects[3]).Y, + () => Is.EqualTo(383.99997).Within(0.00001)); + + // Ensure exporter legacy conversion is correct + AddStep("export", () => + { + outStream = new MemoryStream(); + + new LegacyBeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null); + }); + + AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream)); + AddAssert("second slider is snapped", + () => ((IHasXPosition)beatmap.Beatmap.HitObjects[1]).X, + () => Is.EqualTo(0).Within(0.00001)); + AddAssert("second slider path is snapped", + () => ((IHasPath)beatmap.Beatmap.HitObjects[1]).Path.ControlPoints[1].Position.X, + () => Is.EqualTo(192).Within(0.00001)); + AddAssert("second hit circle is snapped", + () => ((IHasYPosition)beatmap.Beatmap.HitObjects[3]).Y, + () => Is.EqualTo(384).Within(0.00001)); + } + [Test] public void TestExportStability() { diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index c7cf3fe956..ee2733ad91 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -112,5 +112,20 @@ namespace osu.Game.Tests.Beatmaps } }); } + + [Test] + public void TestRepeatsGeneratedEvenForZeroLengthSlider() + { + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, 0, 2).ToArray(); + + Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); + Assert.That(events[0].Time, Is.EqualTo(start_time)); + + Assert.That(events[1].Type, Is.EqualTo(SliderEventType.Repeat)); + Assert.That(events[1].Time, Is.EqualTo(span_duration)); + + Assert.That(events[3].Type, Is.EqualTo(SliderEventType.Tail)); + Assert.That(events[3].Time, Is.EqualTo(span_duration * 2)); + } } } diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index ab40092b3f..7a05a3da5c 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -32,7 +32,7 @@ namespace osu.Game.Tests.Beatmaps private TestBeatmapDifficultyCache difficultyCache; - private IBindable starDifficultyBindable; + private IBindable starDifficultyBindable; [BackgroundDependencyLoader] private void load(OsuGameBase osu) @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps starDifficultyBindable = difficultyCache.GetBindableDifficulty(importedSet.Beatmaps.First()); }); - AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value?.Stars == BASE_STARS); + AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value.Stars == BASE_STARS); } [Test] @@ -67,13 +67,13 @@ namespace osu.Game.Tests.Beatmaps }); 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); + 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); + 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); + AddUntilStep($"star difficulty -> {BASE_STARS + 1.75}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.75); } [Test] @@ -88,15 +88,15 @@ namespace osu.Game.Tests.Beatmaps }); AddStep("change selected mod to DA", () => SelectedMods.Value = new[] { difficultyAdjust = new OsuModDifficultyAdjust() }); - AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value?.Stars == BASE_STARS); + 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); + 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); + AddUntilStep("star difficulty -> 0", () => starDifficultyBindable.Value.Stars == 0); } [Test] diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index ef4d4f683a..5c7f0b0a2f 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.cs @@ -42,6 +42,7 @@ namespace osu.Game.Tests.Chat sentMessages = new List(); silencedUserIds = new List(); + ((DummyAPIAccess)API).LocalUserState.Blocks.Clear(); ((DummyAPIAccess)API).HandleRequest = req => { switch (req) @@ -63,6 +64,10 @@ namespace osu.Game.Tests.Chat silencedUserIds.Clear(); return true; + case GetMessagesRequest getMessages: + getMessages.TriggerSuccess(sentMessages); + return true; + case GetUpdatesRequest updatesRequest: updatesRequest.TriggerSuccess(new GetUpdatesResponse { @@ -161,6 +166,60 @@ namespace osu.Game.Tests.Chat AddUntilStep("/help command received", () => channel.Messages.Last().Content.Contains("Supported commands")); } + [Test] + public void TestBlockedUserMessagesAreDeletedFromInitialMessageBatch() + { + Channel channel = null; + + AddStep("create channel", () => channel = createChannel(1, ChannelType.Public)); + AddStep("post a message from blocked user", () => sentMessages.Add(new Message + { + ChannelId = channel.Id, + Content = "i am blocked", + SenderId = 1234 + })); + AddStep("mark user as blocked", () => ((DummyAPIAccess)API).LocalUserState.Blocks.Add(new APIRelation + { + TargetUser = new APIUser { Username = "blocked", Id = 1234 }, + TargetID = 1234, + })); + + AddStep("join channel and select it", () => + { + channelManager.JoinChannel(channel); + channelManager.CurrentChannel.Value = channel; + }); + AddAssert("channel has no messages", () => channel.Messages, () => Is.Empty); + } + + [Test] + public void TestBlockedUserMessagesAreDeletedImmediatelyOnBlock() + { + Channel channel = null; + + AddStep("create channel", () => channel = createChannel(1, ChannelType.Public)); + + AddStep("join channel and select it", () => + { + channelManager.JoinChannel(channel); + channelManager.CurrentChannel.Value = channel; + }); + AddStep("post a message from blocked user", () => sentMessages.Add(new Message + { + ChannelId = channel.Id, + Content = "i am blocked", + SenderId = 1234 + })); + AddUntilStep("channel has message", () => channel.Messages, () => Is.Not.Empty); + + AddStep("block user", () => ((DummyAPIAccess)API).LocalUserState.Blocks.Add(new APIRelation + { + TargetUser = new APIUser { Username = "blocked", Id = 1234 }, + TargetID = 1234, + })); + AddAssert("channel has no messages", () => channel.Messages, () => Is.Empty); + } + private void handlePostMessageRequest(PostMessageRequest request) { var message = new Message(++currentMessageId) diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs index c40624a3a0..bae8e7c76a 100644 --- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs +++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs @@ -62,12 +62,11 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Run background processor", () => - { - Add(new TestBackgroundDataStoreProcessor()); - }); + TestBackgroundDataStoreProcessor processor = null!; + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); + AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("wait for difficulties repopulated", () => + AddAssert("Difficulties repopulated", () => { return Realm.Run(r => { @@ -101,13 +100,10 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Run background processor", () => - { - Add(new TestBackgroundDataStoreProcessor()); - }); + TestBackgroundDataStoreProcessor processor = null!; + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); AddWaitStep("wait some", 500); - AddAssert("Difficulty still not populated", () => { return Realm.Run(r => @@ -118,8 +114,9 @@ namespace osu.Game.Tests.Database }); AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying); + AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("wait for difficulties repopulated", () => + AddAssert("Difficulties repopulated", () => { return Realm.Run(r => { @@ -151,9 +148,11 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Run background processor", () => Add(new TestBackgroundDataStoreProcessor())); + TestBackgroundDataStoreProcessor processor = null!; + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); + AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("Score version upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(LegacyScoreEncoder.LATEST_VERSION)); + AddAssert("Score version upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(LegacyScoreEncoder.LATEST_VERSION)); AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False); } @@ -183,7 +182,7 @@ namespace osu.Game.Tests.Database AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); AddUntilStep("Wait for completion", () => processor.Completed); - AddUntilStep("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True); + AddAssert("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True); AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(scoreVersion)); } diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 38746f2567..f3ca665380 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -1018,6 +1018,49 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestBeatmapFilesInNestedDirectoriesAreIgnored() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var store = new RealmRulesetStore(realm, storage); + + string? temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + var subdirectory = Directory.CreateDirectory(Path.Combine(extractedFolder, "subdir")); + string modifiedCopyPath = Path.Combine(subdirectory.FullName, "duplicate.osu"); + File.Copy(Directory.GetFiles(extractedFolder, "*.osu").First(), modifiedCopyPath); + + using (var stream = File.OpenWrite(modifiedCopyPath)) + using (var textWriter = new StreamWriter(stream)) + await textWriter.WriteLineAsync("# adding a comment so that the hashes are different"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + await importer.Import(temp); + + EnsureLoaded(realm.Realm); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + [Test] public void TestImportNestedStructure() { diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs index ddf207342a..29aec73770 100644 --- a/osu.Game.Tests/Database/RulesetStoreTests.cs +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealm((realm, storage) => { - var rulesets = new RealmRulesetStore(realm, storage); + using var rulesets = new RealmRulesetStore(realm, storage); Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); Assert.AreEqual(4, realm.Realm.All().Count()); @@ -36,8 +36,8 @@ namespace osu.Game.Tests.Database { RunTestWithRealm((realm, storage) => { - var rulesets = new RealmRulesetStore(realm, storage); - var rulesets2 = new RealmRulesetStore(realm, storage); + using var rulesets = new RealmRulesetStore(realm, storage); + using var rulesets2 = new RealmRulesetStore(realm, storage); Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); Assert.AreEqual(4, rulesets2.AvailableRulesets.Count()); @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealm((realm, storage) => { - var rulesets = new RealmRulesetStore(realm, storage); + using var rulesets = new RealmRulesetStore(realm, storage); Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged); Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged); @@ -79,7 +79,7 @@ namespace osu.Game.Tests.Database Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.True); // Availability is updated on construction of a RealmRulesetStore - _ = new RealmRulesetStore(realm, storage); + using var _ = new RealmRulesetStore(realm, storage); Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.False); }); @@ -104,13 +104,13 @@ namespace osu.Game.Tests.Database Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.True); // Availability is updated on construction of a RealmRulesetStore - _ = new RealmRulesetStore(realm, storage); + using var _ = new RealmRulesetStore(realm, storage); Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.False); // Simulate the ruleset getting updated LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; - _ = new RealmRulesetStore(realm, storage); + using var __ = new RealmRulesetStore(realm, storage); Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.True); }); diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs index 61ee6a3663..c2a712b580 100644 --- a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using ManagedBass; using Moq; using NUnit.Framework; using osu.Framework.Audio.Track; @@ -10,7 +11,9 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; +using osuTK.Audio; namespace osu.Game.Tests.Editing.Checks { @@ -28,9 +31,13 @@ namespace osu.Game.Tests.Editing.Checks { BeatmapInfo = new BeatmapInfo { - Metadata = new BeatmapMetadata { AudioFile = "abc123.jpg" } + Metadata = new BeatmapMetadata() } }; + + // 0 = No output device. This still allows decoding. + if (!Bass.Init(0) && Bass.LastError != Errors.Already) + throw new AudioException("Could not initialize Bass."); } [Test] @@ -54,6 +61,14 @@ namespace osu.Game.Tests.Editing.Checks Assert.That(check.Run(context), Is.Empty); } + [Test] + public void TestAcceptableOgg() + { + var context = getContext(208, useOgg: true); + + Assert.That(check.Run(context), Is.Empty); + } + [Test] public void TestNullBitrate() { @@ -87,6 +102,17 @@ namespace osu.Game.Tests.Editing.Checks Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooHighBitrate); } + [Test] + public void TestTooHighBitrateOgg() + { + var context = getContext(250, useOgg: true); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooHighBitrate); + } + [Test] public void TestTooLowBitrate() { @@ -98,24 +124,41 @@ namespace osu.Game.Tests.Editing.Checks Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooLowBitrate); } - private BeatmapVerifierContext getContext(int? audioBitrate) + private BeatmapVerifierContext getContext(int? audioBitrate, bool useOgg = false) { - return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(audioBitrate).Object); + // Update the audio filename and beatmapset files based on the format being tested + string audioFileName = useOgg ? "abc123.ogg" : "abc123.mp3"; + string fileExtension = useOgg ? "ogg" : "mp3"; + + beatmap.Metadata.AudioFile = audioFileName; + beatmap.BeatmapInfo.BeatmapSet = new BeatmapSetInfo + { + Files = { CheckTestHelpers.CreateMockFile(fileExtension) } + }; + + return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(audioBitrate, useOgg).Object); } /// /// Returns the mock of the working beatmap with the given audio properties. /// /// The bitrate of the audio file the beatmap uses. - private Mock getMockWorkingBeatmap(int? audioBitrate) + /// Whether to use an OGG sample instead of MP3. + private Mock getMockWorkingBeatmap(int? audioBitrate, bool useOgg = false) { var mockTrack = new Mock(new FramedClock(), "virtual"); mockTrack.SetupGet(t => t.Bitrate).Returns(audioBitrate); + // Use real audio samples for format detection + string samplePath = useOgg ? "Samples/test-sample.ogg" : "Samples/test-sample-cut.mp3"; + var mockWorkingBeatmap = new Mock(); mockWorkingBeatmap.SetupGet(w => w.Beatmap).Returns(beatmap); mockWorkingBeatmap.SetupGet(w => w.Track).Returns(mockTrack.Object); + // Return a fresh stream each time GetStream is called to avoid disposed stream issues + mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(() => TestResources.OpenResource(samplePath)); + return mockWorkingBeatmap; } } diff --git a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs index b5c6568583..fd63e1b05d 100644 --- a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs @@ -57,6 +57,16 @@ namespace osu.Game.Tests.Editing.Checks }); } + [Test] + public void TestCirclesAlmostConcurrentWarning() + { + assertAlmostConcurrentSame(new List + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 108 } + }); + } + [Test] public void TestSlidersSeparate() { @@ -97,6 +107,16 @@ namespace osu.Game.Tests.Editing.Checks }); } + [Test] + public void TestSliderAndCircleAlmostConcurrent() + { + assertAlmostConcurrentDifferent(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + new HitCircle { StartTime = 408 } + }); + } + [Test] public void TestManyObjectsConcurrent() { @@ -110,38 +130,14 @@ namespace osu.Game.Tests.Editing.Checks var issues = check.Run(getContext(hitobjects)).ToList(); Assert.That(issues, Has.Count.EqualTo(3)); - Assert.That(issues.Where(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent).ToList(), Has.Count.EqualTo(2)); - Assert.That(issues.Any(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame)); - } + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrent)); - [Test] - public void TestHoldNotesSeparateOnSameColumn() - { - assertOk(new List - { - getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, - getHoldNoteMock(startTime: 500, endTime: 900.75d, column: 1).Object - }); - } + // Should have 1 same-type concurrent (Slider & Slider) and 2 different-type concurrent (Slider & Circle) + var sameTypeIssues = issues.Where(issue => issue.ToString().Contains("s are concurrent here")).ToList(); + var differentTypeIssues = issues.Where(issue => issue.ToString().Contains(" and ") && issue.ToString().Contains("are concurrent here")).ToList(); - [Test] - public void TestHoldNotesConcurrentOnDifferentColumns() - { - assertOk(new List - { - getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, - getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 2).Object - }); - } - - [Test] - public void TestHoldNotesConcurrentOnSameColumn() - { - assertConcurrentSame(new List - { - getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, - getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 1).Object - }); + Assert.That(sameTypeIssues, Has.Count.EqualTo(1)); + Assert.That(differentTypeIssues, Has.Count.EqualTo(2)); } private Mock getSliderMock(double startTime, double endTime, int repeats = 0) @@ -174,7 +170,8 @@ namespace osu.Game.Tests.Editing.Checks var issues = check.Run(getContext(hitobjects)).ToList(); Assert.That(issues, Has.Count.EqualTo(count)); - Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrent)); + Assert.That(issues.All(issue => issue.ToString().Contains("s are concurrent here"))); } private void assertConcurrentDifferent(List hitobjects, int count = 1) @@ -182,7 +179,26 @@ namespace osu.Game.Tests.Editing.Checks var issues = check.Run(getContext(hitobjects)).ToList(); Assert.That(issues, Has.Count.EqualTo(count)); - Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrent)); + Assert.That(issues.All(issue => issue.ToString().Contains(" and ") && issue.ToString().Contains("are concurrent here"))); + } + + private void assertAlmostConcurrentSame(List hitobjects) + { + var issues = check.Run(getContext(hitobjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateAlmostConcurrent)); + Assert.That(issues.All(issue => issue.ToString().Contains("s are less than 10ms apart"))); + } + + private void assertAlmostConcurrentDifferent(List hitobjects) + { + var issues = check.Run(getContext(hitobjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateAlmostConcurrent)); + Assert.That(issues.All(issue => issue.ToString().Contains(" and ") && issue.ToString().Contains("are less than 10ms apart"))); } private BeatmapVerifierContext getContext(List hitobjects) diff --git a/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs b/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs index cb1cf21734..6da391aa8d 100644 --- a/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckHitsoundsFormatTest.cs @@ -117,6 +117,52 @@ namespace osu.Game.Tests.Editing.Checks } } + [Test] + public void TestBeatmapAudioTracksExemptedFromCheck() + { + using (var resourceStream = TestResources.OpenResource("Samples/corrupt.wav")) + { + var beatmapSet = new BeatmapSetInfo + { + Files = + { + CheckTestHelpers.CreateMockFile("wav"), + CheckTestHelpers.CreateMockFile("mp3") + } + }; + + var firstPlayable = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BeatmapSet = beatmapSet, + Metadata = new BeatmapMetadata { AudioFile = beatmapSet.Files[0].Filename } + } + }; + var firstWorking = new Mock(firstPlayable, null, null); + firstWorking.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); + + var secondPlayable = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BeatmapSet = beatmapSet, + Metadata = new BeatmapMetadata { AudioFile = beatmapSet.Files[1].Filename } + } + }; + var secondWorking = new Mock(secondPlayable, null, null); + secondWorking.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); + + var context = new BeatmapVerifierContext( + new BeatmapVerifierContext.VerifiedBeatmap(firstWorking.Object, firstPlayable), + [new BeatmapVerifierContext.VerifiedBeatmap(secondWorking.Object, secondPlayable)], + DifficultyRating.ExpertPlus); + + var issues = check.Run(context).ToList(); + Assert.That(issues, Is.Empty); + } + } + private BeatmapVerifierContext getContext(Stream? resourceStream) { var mockWorkingBeatmap = new Mock(beatmap, null, null); diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentAudioTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentAudioTest.cs new file mode 100644 index 0000000000..2846c3d6d5 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentAudioTest.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.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Models; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckInconsistentAudioTest + { + private CheckInconsistentAudio check = null!; + + [SetUp] + public void Setup() + { + check = new CheckInconsistentAudio(); + } + + [Test] + public void TestConsistentAudio() + { + var beatmaps = createBeatmapSetWithAudio("audio.mp3", "audio.mp3"); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestInconsistentAudio() + { + var beatmaps = createBeatmapSetWithAudio("audio1.mp3", "audio2.mp3"); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckInconsistentAudio.IssueTemplateInconsistentAudio); + Assert.That(issues.Single().ToString(), Contains.Substring("audio1.mp3")); + Assert.That(issues.Single().ToString(), Contains.Substring("audio2.mp3")); + } + + [Test] + public void TestInconsistentAudioWithNull() + { + var beatmaps = createBeatmapSetWithAudio("audio.mp3", null); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckInconsistentAudio.IssueTemplateInconsistentAudio); + Assert.That(issues.Single().ToString(), Contains.Substring("audio.mp3")); + Assert.That(issues.Single().ToString(), Contains.Substring("not set")); + } + + [Test] + public void TestInconsistentAudioWithEmptyString() + { + var beatmaps = createBeatmapSetWithAudio("audio.mp3", ""); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckInconsistentAudio.IssueTemplateInconsistentAudio); + Assert.That(issues.Single().ToString(), Contains.Substring("audio.mp3")); + Assert.That(issues.Single().ToString(), Contains.Substring("not set")); + } + + [Test] + public void TestBothAudioNotSet() + { + var beatmaps = createBeatmapSetWithAudio("", ""); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMultipleInconsistencies() + { + var beatmaps = createBeatmapSetWithAudio("audio1.mp3", "audio2.mp3", "audio3.mp3"); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentAudio.IssueTemplateInconsistentAudio)); + } + + [Test] + public void TestSingleDifficulty() + { + var beatmaps = createBeatmapSetWithAudio("audio.mp3"); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + private IBeatmap createBeatmapWithAudio(string audioFile, RealmNamedFileUsage? file) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { AudioFile = audioFile }, + BeatmapSet = new BeatmapSetInfo() + } + }; + + if (file != null) + beatmap.BeatmapInfo.BeatmapSet!.Files.Add(file); + + return beatmap; + } + + private IBeatmap[] createBeatmapSetWithAudio(params string?[] audioFiles) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[audioFiles.Length]; + + for (int i = 0; i < audioFiles.Length; i++) + { + string? audioFile = audioFiles[i]; + var file = !string.IsNullOrEmpty(audioFile) ? CheckTestHelpers.CreateMockFile("mp3") : null; + + beatmaps[i] = createBeatmapWithAudio(audioFile ?? "", file); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + beatmaps[i].BeatmapInfo.DifficultyName = $"Difficulty {i + 1}"; + beatmapSet.Beatmaps.Add(beatmaps[i].BeatmapInfo); + } + + return beatmaps; + } + + private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IBeatmap[] allDifficulties) + { + var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap); + var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList(); + + return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentMetadataTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentMetadataTest.cs new file mode 100644 index 0000000000..09d731152d --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentMetadataTest.cs @@ -0,0 +1,215 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Models; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckInconsistentMetadataTest + { + private CheckInconsistentMetadata check = null!; + + [SetUp] + public void Setup() + { + check = new CheckInconsistentMetadata(); + } + + [Test] + public void TestConsistentMetadata() + { + var metadata = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2"); + var beatmaps = createBeatmapSetWithMetadata(metadata, metadata); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestInconsistentArtist() + { + var metadata1 = createMetadata("Artist One", "Test Title", "Test Source", "Test Creator", "tag1 tag2"); + var metadata2 = createMetadata("Artist Two", "Test Title", "Test Source", "Test Creator", "tag1 tag2"); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields); + Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent artist fields")); + Assert.That(issues[0].ToString(), Contains.Substring("Artist One")); + Assert.That(issues[0].ToString(), Contains.Substring("Artist Two")); + } + + [Test] + public void TestInconsistentTitle() + { + var metadata1 = createMetadata("Test Artist", "Title One", "Test Source", "Test Creator", "tag1 tag2"); + var metadata2 = createMetadata("Test Artist", "Title Two", "Test Source", "Test Creator", "tag1 tag2"); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields); + Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent title fields")); + } + + [Test] + public void TestInconsistentUnicodeArtist() + { + var metadata1 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2", unicodeArtist: "Test Unicode Artist 1"); + var metadata2 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2", unicodeArtist: "Test Unicode Artist 2"); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields); + Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent unicode artist fields")); + } + + [Test] + public void TestInconsistentSource() + { + var metadata1 = createMetadata("Test Artist", "Test Title", "Source One", "Test Creator", "tag1 tag2"); + var metadata2 = createMetadata("Test Artist", "Test Title", "Source Two", "Test Creator", "tag1 tag2"); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields); + Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent source fields")); + } + + [Test] + public void TestInconsistentCreator() + { + var metadata1 = createMetadata("Test Artist", "Test Title", "Test Source", "Creator One", "tag1 tag2"); + var metadata2 = createMetadata("Test Artist", "Test Title", "Test Source", "Creator Two", "tag1 tag2"); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields); + Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent creator fields")); + } + + [Test] + public void TestInconsistentTags() + { + var metadata1 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2 tag3"); + var metadata2 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag4 tag5"); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentTags); + Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent tags")); + Assert.That(issues[0].ToString(), Contains.Substring("tag2 tag3 tag4 tag5")); + } + + [Test] + public void TestMultipleInconsistencies() + { + var metadata1 = createMetadata("Artist One", "Title One", "Test Source", "Test Creator", "tag1 tag2"); + var metadata2 = createMetadata("Artist Two", "Title Two", "Test Source", "Test Creator", "tag3 tag4"); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(3)); // artist, title, tags + Assert.That(issues.Count(i => i.Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields), Is.EqualTo(2)); + Assert.That(issues.Count(i => i.Template is CheckInconsistentMetadata.IssueTemplateInconsistentTags), Is.EqualTo(1)); + } + + [Test] + public void TestSingleDifficulty() + { + var metadata = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2"); + var beatmaps = createBeatmapSetWithMetadata(metadata); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestEmptyStringFieldsAreConsistent() + { + var metadata1 = createMetadata("Test Artist", "Test Title", "", "Test Creator", ""); + var metadata2 = createMetadata("Test Artist", "Test Title", "", "Test Creator", ""); + var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + private BeatmapMetadata createMetadata(string artist, string title, string source, string creator, string tags, string unicodeArtist = "", string unicodeTitle = "") + { + return new BeatmapMetadata(new RealmUser { Username = creator }) + { + Artist = artist, + Title = title, + Source = source, + Tags = tags, + ArtistUnicode = unicodeArtist, + TitleUnicode = unicodeTitle + }; + } + + private IBeatmap[] createBeatmapSetWithMetadata(params BeatmapMetadata[] metadata) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[metadata.Length]; + + for (int i = 0; i < metadata.Length; i++) + { + beatmaps[i] = createBeatmapWithMetadata(metadata[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + // Configure the beatmapset to contain all the beatmap infos + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private Beatmap createBeatmapWithMetadata(BeatmapMetadata metadata, string difficultyName) + { + return new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = metadata + } + }; + } + + private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IBeatmap[] allDifficulties) + { + var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap); + var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList(); + + return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentSettingsTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentSettingsTest.cs new file mode 100644 index 0000000000..079c6855a9 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentSettingsTest.cs @@ -0,0 +1,272 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osuTK; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Tests.Beatmaps; +using osu.Game.Storyboards; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckInconsistentSettingsTest + { + private CheckInconsistentSettings check = null!; + + [SetUp] + public void Setup() + { + check = new CheckInconsistentSettings(); + } + + [Test] + public void TestConsistentSettings() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(audioLeadIn: 1000, countdown: CountdownType.Normal, epilepsyWarning: false), + createSettings(audioLeadIn: 1000, countdown: CountdownType.Normal, epilepsyWarning: false) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestInconsistentAudioLeadIn() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(audioLeadIn: 1000), + createSettings(audioLeadIn: 2000) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Audio lead-in")); + Assert.That(issues[0].ToString(), Contains.Substring("1000")); + Assert.That(issues[0].ToString(), Contains.Substring("2000")); + } + + [Test] + public void TestInconsistentCountdown() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(countdown: CountdownType.Normal), + createSettings(countdown: CountdownType.None) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Countdown")); + Assert.That(issues[0].ToString(), Contains.Substring("Normal")); + Assert.That(issues[0].ToString(), Contains.Substring("None")); + } + + [Test] + public void TestInconsistentCountdownOffset() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(countdownOffset: 100), + createSettings(countdownOffset: 200) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Countdown offset")); + } + + [Test] + public void TestInconsistentEpilepsyWarning() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(epilepsyWarning: true), + createSettings(epilepsyWarning: false) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Epilepsy warning")); + } + + [Test] + public void TestInconsistentLetterboxInBreaks() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(letterboxInBreaks: true), + createSettings(letterboxInBreaks: false) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Letterbox during breaks")); + } + + [Test] + public void TestInconsistentSamplesMatchPlaybackRate() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(samplesMatchPlaybackRate: true), + createSettings(samplesMatchPlaybackRate: false) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Samples match playback rate")); + } + + [Test] + public void TestInconsistentWidescreenSupport() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(widescreenStoryboard: true), + createSettings(widescreenStoryboard: false) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestInconsistentWidescreenSupportWithStoryboard() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(widescreenStoryboard: true), + createSettings(widescreenStoryboard: false) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps, hasStoryboard: true); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Widescreen support")); + } + + [Test] + public void TestInconsistentSliderTickRate() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(sliderTickRate: 1.0), + createSettings(sliderTickRate: 2.0) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting); + Assert.That(issues[0].ToString(), Contains.Substring("Tick Rate")); + } + + [Test] + public void TestMultipleInconsistencies() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(audioLeadIn: 1000, countdown: CountdownType.Normal, epilepsyWarning: false), + createSettings(audioLeadIn: 2000, countdown: CountdownType.None, epilepsyWarning: true) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(3)); + Assert.That(issues.Count(i => i.ToString().Contains("Audio lead-in")), Is.EqualTo(1)); + Assert.That(issues.Count(i => i.ToString().Contains("Countdown")), Is.EqualTo(1)); + Assert.That(issues.Count(i => i.ToString().Contains("Epilepsy warning")), Is.EqualTo(1)); + } + + [Test] + public void TestSingleDifficulty() + { + var beatmaps = createBeatmapSetWithSettings( + createSettings(audioLeadIn: 1000) + ); + var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps); + + Assert.That(check.Run(context), Is.Empty); + } + + private Beatmap createSettings( + double audioLeadIn = 0, + CountdownType countdown = CountdownType.None, + int countdownOffset = 0, + bool epilepsyWarning = false, + bool letterboxInBreaks = false, + bool samplesMatchPlaybackRate = false, + bool widescreenStoryboard = false, + double sliderTickRate = 1.0) + { + return new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = "Test Difficulty", + StarRating = 5.0 + }, + AudioLeadIn = audioLeadIn, + Countdown = countdown, + CountdownOffset = countdownOffset, + EpilepsyWarning = epilepsyWarning, + LetterboxInBreaks = letterboxInBreaks, + SamplesMatchPlaybackRate = samplesMatchPlaybackRate, + WidescreenStoryboard = widescreenStoryboard, + Difficulty = new BeatmapDifficulty + { + SliderTickRate = sliderTickRate + } + }; + } + + private IBeatmap[] createBeatmapSetWithSettings(params IBeatmap[] beatmaps) + { + var beatmapSet = new BeatmapSetInfo(); + + for (int i = 0; i < beatmaps.Length; i++) + { + beatmaps[i].BeatmapInfo.DifficultyName = $"Difficulty {i + 1}"; + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + beatmapSet.Beatmaps.Add(beatmaps[i].BeatmapInfo); + } + + return beatmaps; + } + + private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IBeatmap[] allDifficulties, bool hasStoryboard = false) + { + Storyboard? storyboard = null; + + if (hasStoryboard) + { + storyboard = new Storyboard(); + storyboard.GetLayer("Background").Add(new StoryboardSprite("test.png", Anchor.Centre, Vector2.Zero)); + } + + var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap, storyboard), currentBeatmap); + var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b, storyboard), b)).ToList(); + + return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs new file mode 100644 index 0000000000..afcb38c858 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs @@ -0,0 +1,254 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckInconsistentTimingControlPointsTest + { + private CheckInconsistentTimingControlPoints check = null!; + + [SetUp] + public void Setup() + { + check = new CheckInconsistentTimingControlPoints(); + } + + [Test] + public void TestConsistentTiming() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0, 2000.0 }, // Timing at 1000ms and 2000ms + new[] { 1000.0, 2000.0 } // Same timing + ); + + var context = createContext(beatmaps[0], beatmaps); + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMissingTimingPoint() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0, 2000.0 }, // Reference has timing at 1000ms and 2000ms + new[] { 1000.0 } // Second difficulty missing timing at 2000ms + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateMissingTimingPoint)); + } + + [Test] + public void TestInconsistentBPM() + { + var beatmaps = createBeatmapSetWithBPM( + new[] { (1000.0, 500.0) }, // Reference: 120 BPM (500ms beat length) + new[] { (1000.0, 600.0) } // Second: 100 BPM (600ms beat length) + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateInconsistentBPM)); + } + + [Test] + public void TestInconsistentMeter() + { + var beatmaps = createBeatmapSetWithMeter( + new[] { (1000.0, TimeSignature.SimpleQuadruple) }, // Reference: 4/4 + new[] { (1000.0, TimeSignature.SimpleTriple) } // Second: 3/4 + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateInconsistentMeter)); + } + + [Test] + public void TestDecimalOffset() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0 }, // Reference at exactly 1000ms + new[] { 1000.5 } // Second at 1000.5ms (decimal difference) + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateMissingTimingPointMinor)); + } + + [Test] + public void TestSingleDifficulty() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0, 2000.0 } // Only one difficulty + ); + + var context = createContext(beatmaps[0], beatmaps); + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestExtraTimingPoint() + { + var beatmaps = createBeatmapSetWithTiming( + new[] { 1000.0 }, // Reference has timing at 1000ms + new[] { 1000.0, 2000.0 } // Second has additional timing at 2000ms + ); + + var context = createContext(beatmaps[0], beatmaps); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateExtraTimingPoint)); + } + + private IBeatmap[] createBeatmapSetWithTiming(params double[][] timingPoints) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingPoints.Length]; + + for (int i = 0; i < timingPoints.Length; i++) + { + beatmaps[i] = createBeatmapWithTiming(timingPoints[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap[] createBeatmapSetWithBPM(params (double time, double beatLength)[][] timingData) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingData.Length]; + + for (int i = 0; i < timingData.Length; i++) + { + beatmaps[i] = createBeatmapWithBPM(timingData[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap[] createBeatmapSetWithMeter(params (double time, TimeSignature meter)[][] timingData) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[timingData.Length]; + + for (int i = 0; i < timingData.Length; i++) + { + beatmaps[i] = createBeatmapWithMeter(timingData[i], $"Difficulty {i + 1}"); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + foreach (var beatmap in beatmaps) + beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap createBeatmapWithTiming(double[] timingPoints, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + }, + ControlPointInfo = new ControlPointInfo() + }; + + foreach (double time in timingPoints) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = 500 // 120 BPM + }); + } + + return beatmap; + } + + private IBeatmap createBeatmapWithBPM((double time, double beatLength)[] timingData, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + }, + ControlPointInfo = new ControlPointInfo() + }; + + foreach ((double time, double beatLength) in timingData) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = beatLength + }); + } + + return beatmap; + } + + private IBeatmap createBeatmapWithMeter((double time, TimeSignature meter)[] timingData, string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata() + }, + ControlPointInfo = new ControlPointInfo() + }; + + foreach ((double time, var meter) in timingData) + { + beatmap.ControlPointInfo.Add(time, new TimingControlPoint + { + BeatLength = 500, // 120 BPM + TimeSignature = meter + }); + } + + return beatmap; + } + + private BeatmapVerifierContext createContext(IBeatmap currentBeatmap, IBeatmap[] allDifficulties) + { + var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap); + var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList(); + + return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs new file mode 100644 index 0000000000..91333d2916 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs @@ -0,0 +1,251 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckLowestDiffDrainTimeTest + { + private TestCheckLowestDiffDrainTime check = null!; + + [SetUp] + public void Setup() + { + check = new TestCheckLowestDiffDrainTime(); + } + + [Test] + public void TestSingleDifficultyMeetsRequirement() + { + var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 3.5, "Hard"); // 4 minutes + assertOk(beatmap); + } + + [Test] + public void TestSingleDifficultyTooShort() + { + var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 3.5, "Hard"); // 2 minutes - too short for Hard + assertTooShort(beatmap); + } + + [Test] + public void TestHardDifficultyAtThreshold() + { + var beatmap = createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"); // Exactly 3:30 + assertOk(beatmap); + } + + [Test] + public void TestHardDifficultyJustUnderThreshold() + { + var beatmap = createBeatmapWithDrainTime((3 * 60 + 29) * 1000, 3.5, "Hard"); // 3:29 - just under threshold + assertTooShort(beatmap); + } + + [Test] + public void TestInsaneDifficultyAtThreshold() + { + var beatmap = createBeatmapWithDrainTime((4 * 60 + 15) * 1000, 4.5, "Insane"); // Exactly 4:15 + assertOk(beatmap); + } + + [Test] + public void TestInsaneDifficultyTooShort() + { + var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"); // 4:00 - too short for Insane + assertTooShort(beatmap); + } + + [Test] + public void TestExpertDifficultyAtThreshold() + { + var beatmap = createBeatmapWithDrainTime(5 * 60 * 1000, 5.5, "Expert"); // Exactly 5:00 + assertOk(beatmap); + } + + [Test] + public void TestExpertDifficultyTooShort() + { + var beatmap = createBeatmapWithDrainTime((4 * 60 + 30) * 1000, 5.5, "Expert"); // 4:30 - too short for Expert + assertTooShort(beatmap); + } + + [Test] + public void TestEasyDifficultyMeetsRequirement() + { + var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 1.5, "Easy"); // 2 minutes - should be ok for Easy + assertOk(beatmap); + } + + [Test] + public void TestNormalDifficultyMeetsRequirement() + { + var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 2.5, "Normal"); // 2 minutes - should be ok for Normal + assertOk(beatmap); + } + + [Test] + public void TestMultipleDifficultiesMeetsRequirement() + { + var difficulties = new List + { + createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"), // Hard - lowest difficulty, 3:30 + createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 4.5, "Insane"), + createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 5.5, "Expert") + }; + + // All should be ok because lowest difficulty is Hard and drain time meets Hard requirement + assertOkWithMultipleDifficulties(difficulties[0], difficulties); + assertOkWithMultipleDifficulties(difficulties[1], difficulties); + assertOkWithMultipleDifficulties(difficulties[2], difficulties); + } + + [Test] + public void TestMultipleDifficultiesTooShort() + { + var difficulties = new List + { + createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"), // Insane - lowest difficulty, 4:00 + createBeatmapWithDrainTime(4 * 60 * 1000, 5.5, "Expert") // Same drain time + }; + + // Should be too short because lowest difficulty is Insane and requires 4:15 + assertTooShortWithMultipleDifficulties(difficulties[0], difficulties); + assertTooShortWithMultipleDifficulties(difficulties[1], difficulties); + } + + [Test] + public void TestPlayTimeVsDrainTimeNotHighestDifficulty() + { + var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time + expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break + + var difficulties = new List + { + expertBeatmap, // Expert - 5:00 play, 4:20 drain + createBeatmapWithPlayTime(5 * 60 * 1000, 6.5, "ExpertPlus") // ExpertPlus - highest difficulty + }; + + // The Expert difficulty (not highest) should use play time (5:00) and pass the Expert requirement + assertOkWithMultipleDifficulties(difficulties[0], difficulties); + } + + [Test] + public void TestPlayTimeVsDrainTimeHighestDifficulty() + { + var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time + expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break + + // As the highest difficulty with breaks > 30s, it should use drain time and fail + assertTooShort(expertBeatmap); + } + + private IBeatmap createBeatmapWithDrainTime(double drainTimeMs, double starRating = 3.5, string difficultyName = "Default") + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + StarRating = starRating, + DifficultyName = difficultyName, + Ruleset = new OsuRuleset().RulesetInfo + }, + HitObjects = new List + { + new HitObject { StartTime = 0 }, + new HitObject { StartTime = drainTimeMs } // Last object at drain time + } + }; + + return beatmap; + } + + private IBeatmap createBeatmapWithPlayTime(double playTimeMs, double starRating = 3.5, string difficultyName = "Default") + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + StarRating = starRating, + DifficultyName = difficultyName, + Ruleset = new OsuRuleset().RulesetInfo + }, + HitObjects = new List + { + new HitObject { StartTime = 0 }, + new HitObject { StartTime = playTimeMs } // Last object at play time + } + }; + + return beatmap; + } + + private void assertOk(IBeatmap beatmap) + { + var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating); + + Assert.That(check.Run(context), Is.Empty); + } + + private void assertTooShort(IBeatmap beatmap) + { + var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort); + } + + private void assertOkWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties) + { + var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties); + + Assert.That(check.Run(context), Is.Empty); + } + + private void assertTooShortWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties) + { + var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort); + } + + private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties) + { + var difficultiesArray = allDifficulties.ToArray(); + var currentDifficultyRating = StarDifficulty.GetDifficultyRating(currentBeatmap.BeatmapInfo.StarRating); + + var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap); + var verifiedOtherBeatmaps = difficultiesArray.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList(); + + return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, currentDifficultyRating); + } + + private class TestCheckLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // Same thresholds as `CheckOsuLowestDiffDrainTime` for testing + yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert"); + } + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckMissingGenreLanguageTest.cs b/osu.Game.Tests/Editing/Checks/CheckMissingGenreLanguageTest.cs new file mode 100644 index 0000000000..df5b207ce7 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckMissingGenreLanguageTest.cs @@ -0,0 +1,184 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckMissingGenreLanguageTest + { + private CheckMissingGenreLanguage check = null!; + + [SetUp] + public void Setup() + { + check = new CheckMissingGenreLanguage(); + } + + [Test] + public void TestHasGenreAndLanguage() + { + var beatmap = createBeatmapWithTags("rock english instrumental"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestHasGenreOnly() + { + var beatmap = createBeatmapWithTags("electronic pop"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage); + } + + [Test] + public void TestHasLanguageOnly() + { + var beatmap = createBeatmapWithTags("japanese instrumental"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre); + } + + [Test] + public void TestMissingBoth() + { + var beatmap = createBeatmapWithTags("tag1 tag2 tag3"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre)); + Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage)); + } + + [Test] + public void TestMultiWordGenreHipHop() + { + var beatmap = createBeatmapWithTags("hip hop music"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage); + } + + [Test] + public void TestScatteredMultiWordGenre() + { + var beatmap = createBeatmapWithTags("video hip game hop ost"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage); + } + + [Test] + public void TestCaseInsensitive() + { + var beatmap = createBeatmapWithTags("ROCK JAPANESE"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestMixedCase() + { + var beatmap = createBeatmapWithTags("Rock Japanese"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestSingleWordGenre() + { + var beatmap = createBeatmapWithTags("electronic"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage); + } + + [Test] + public void TestSingleWordLanguage() + { + var beatmap = createBeatmapWithTags("instrumental"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre); + } + + [Test] + public void TestEmptyTags() + { + var beatmap = createBeatmapWithTags(""); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre)); + Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage)); + } + + [Test] + public void TestPartialMultiWordMatch() + { + // Should not match if only one word is found + var beatmap = createBeatmapWithTags("hip music"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre)); + Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage)); + } + + [Test] + public void TestGenreAndLanguageWithExtraTags() + { + var beatmap = createBeatmapWithTags("tag1 rock tag2 english tag3"); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + Assert.That(check.Run(context), Is.Empty); + } + + private IBeatmap createBeatmapWithTags(string tags) + { + return new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { Tags = tags } + } + }; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs index 37b01da6ee..7fbe822e8d 100644 --- a/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; +using System; using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; @@ -16,8 +16,6 @@ namespace osu.Game.Tests.Editing.Checks { private CheckPreviewTime check = null!; - private IBeatmap beatmap = null!; - [SetUp] public void Setup() { @@ -27,62 +25,69 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestPreviewTimeNotSet() { - setNoPreviewTimeBeatmap(); - var content = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + // single difficulty with no preview time + var current = createBeatmapWithPreviewPoint(-1, "Current"); + var context = createContext(current, Array.Empty()); - var issues = check.Run(content).ToList(); + var issues = check.Run(context).ToList(); Assert.That(issues, Has.Count.EqualTo(1)); Assert.That(issues.Single().Template is CheckPreviewTime.IssueTemplateHasNoPreviewTime); } [Test] - public void TestPreviewTimeconflict() + public void TestPreviewTimeConflict() { - setPreviewTimeConflictBeatmap(); + var beatmaps = createBeatmapSetWithPreviewPoint( + ("Current", 10), + ("Test1", 5), + ("Test2", 10) + ); - var content = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + var context = createContext(beatmaps[0], new[] { beatmaps[1], beatmaps[2] }); - var issues = check.Run(content).ToList(); + var issues = check.Run(context).ToList(); Assert.That(issues, Has.Count.EqualTo(1)); Assert.That(issues.Single().Template is CheckPreviewTime.IssueTemplatePreviewTimeConflict); Assert.That(issues.Single().Arguments.FirstOrDefault()?.ToString() == "Test1"); } - private void setNoPreviewTimeBeatmap() + private IBeatmap[] createBeatmapSetWithPreviewPoint(params (string name, int preview)[] entries) { - beatmap = new Beatmap + var beatmapSet = new BeatmapSetInfo(); + var beatmaps = new IBeatmap[entries.Length]; + + for (int i = 0; i < entries.Length; i++) + { + beatmaps[i] = createBeatmapWithPreviewPoint(entries[i].preview, entries[i].name); + beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet; + } + + foreach (var b in beatmaps) + beatmapSet.Beatmaps.Add(b.BeatmapInfo); + + return beatmaps; + } + + private IBeatmap createBeatmapWithPreviewPoint(int previewTime, string difficultyName) + { + return new Beatmap { BeatmapInfo = new BeatmapInfo { - Metadata = new BeatmapMetadata { PreviewTime = -1 }, + DifficultyName = difficultyName, + Metadata = new BeatmapMetadata { PreviewTime = previewTime } } }; } - private void setPreviewTimeConflictBeatmap() + private BeatmapVerifierContext createContext(IBeatmap currentBeatmap, IBeatmap[] otherDifficulties) { - beatmap = new Beatmap - { - BeatmapInfo = new BeatmapInfo - { - Metadata = new BeatmapMetadata { PreviewTime = 10 }, - BeatmapSet = new BeatmapSetInfo(new List - { - new BeatmapInfo - { - DifficultyName = "Test1", - Metadata = new BeatmapMetadata { PreviewTime = 5 }, - }, - new BeatmapInfo - { - DifficultyName = "Test2", - Metadata = new BeatmapMetadata { PreviewTime = 10 }, - }, - }) - } - }; + var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap); + var verifiedOtherBeatmaps = otherDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList(); + + return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus); } } } diff --git a/osu.Game.Tests/Editing/Checks/CheckVideoUsageTest.cs b/osu.Game.Tests/Editing/Checks/CheckVideoUsageTest.cs new file mode 100644 index 0000000000..8e332fb405 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckVideoUsageTest.cs @@ -0,0 +1,179 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckVideoUsageTest + { + private CheckVideoUsage check = null!; + + [SetUp] + public void Setup() + { + check = new CheckVideoUsage(); + } + + [Test] + public void TestConsistentVideoUsage() + { + var beatmap1 = createBeatmapWithVideo("Diff 1", "video.mp4", 1000); + var beatmap2 = createBeatmapWithVideo("Diff 2", "video.mp4", 1000); + + var context = createContext(beatmap1, [beatmap2]); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestDifferentVideoFile() + { + var beatmap1 = createBeatmapWithVideo("Diff 1", "videoA.mp4", 0); + var beatmap2 = createBeatmapWithVideo("Diff 2", "videoB.mp4", 500); + + var context = createContext(beatmap1, [beatmap2]); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateDifferentVideo); + } + + [Test] + public void TestDifferentStartTime() + { + var beatmap1 = createBeatmapWithVideo("Diff 1", "video.mp4", 0); + var beatmap2 = createBeatmapWithVideo("Diff 2", "video.mp4", 500); + + var context = createContext(beatmap1, [beatmap2]); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateDifferentStartTime); + } + + [Test] + public void TestOtherDifficultyMissingVideo() + { + var beatmap1 = createBeatmapWithVideo("Diff 1", "video.mp4", 0); + var beatmap2 = createBeatmapWithoutVideo("Diff 2"); + + var context = createContext(beatmap1, [beatmap2]); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateMissingVideo); + } + + [Test] + public void TestCurrentDifficultyMissingVideo() + { + var beatmap1 = createBeatmapWithoutVideo("Diff 1"); + var beatmap2 = createBeatmapWithVideo("Diff 2", "video.mp4", 0); + + var context = createContext(beatmap1, [beatmap2]); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateMissingVideo); + } + + [Test] + public void TestBothDifficultiesMissingVideo() + { + var beatmap1 = createBeatmapWithoutVideo("Diff 1"); + var beatmap2 = createBeatmapWithoutVideo("Diff 2"); + + var context = createContext(beatmap1, [beatmap2]); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestPairwiseStartTimeMismatchAcrossNonCurrentDifficulties() + { + var beatmapCurrent = createBeatmapWithVideo("Diff A", "A.mp4", 0); + var beatmapB = createBeatmapWithVideo("Diff B", "X.mp4", 1000); + var beatmapC = createBeatmapWithVideo("Diff C", "X.mp4", 2000); + + var context = createContext(beatmapCurrent, [beatmapB, beatmapC]); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(3)); + Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateDifferentVideo), Is.EqualTo(2)); + Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateDifferentStartTime), Is.EqualTo(1)); + } + + [Test] + public void TestPairwiseStartTimeMismatchWhenCurrentMissingVideo() + { + var beatmapCurrent = createBeatmapWithoutVideo("Diff A"); + var beatmapB = createBeatmapWithVideo("Diff B", "X.mp4", 1000); + var beatmapC = createBeatmapWithVideo("Diff C", "X.mp4", 2000); + + var context = createContext(beatmapCurrent, [beatmapB, beatmapC]); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateMissingVideo), Is.EqualTo(1)); + Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateDifferentStartTime), Is.EqualTo(1)); + } + + private BeatmapVerifierContext.VerifiedBeatmap createBeatmapWithVideo(string difficultyName, string path, double startTime) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName + } + }; + + var storyboard = new Storyboard(); + storyboard.GetLayer("Video").Add(new StoryboardVideo(path, startTime)); + + var working = new TestWorkingBeatmap(beatmap, storyboard); + return new BeatmapVerifierContext.VerifiedBeatmap(working, beatmap); + } + + private BeatmapVerifierContext.VerifiedBeatmap createBeatmapWithoutVideo(string difficultyName) + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = difficultyName + } + }; + + var storyboard = new Storyboard(); + // no video added + var working = new TestWorkingBeatmap(beatmap, storyboard); + return new BeatmapVerifierContext.VerifiedBeatmap(working, beatmap); + } + + private BeatmapVerifierContext createContext(BeatmapVerifierContext.VerifiedBeatmap current, BeatmapVerifierContext.VerifiedBeatmap[] others) + { + return new BeatmapVerifierContext( + current, + others.ToList(), + DifficultyRating.ExpertPlus + ); + } + } +} + + diff --git a/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs index bbcf6aac2c..c625346645 100644 --- a/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs +++ b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs @@ -539,5 +539,85 @@ namespace osu.Game.Tests.Editing Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(5000 - OsuHitObject.PREEMPT_MAX)); }); } + + [Test] + public void TestPuttingObjectBetweenBreakEndAndAnotherObjectForcesNewCombo() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + Difficulty = + { + ApproachRate = 10, + }, + HitObjects = + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 4500 }, + new HitCircle { StartTime = 5000, NewCombo = true }, + }, + Breaks = + { + new BreakPeriod(2000, 4000), + } + }); + + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(((HitCircle)beatmap.HitObjects[1]).NewCombo, Is.True); + Assert.That(((HitCircle)beatmap.HitObjects[2]).NewCombo, Is.True); + + Assert.That(((HitCircle)beatmap.HitObjects[0]).ComboIndex, Is.EqualTo(1)); + Assert.That(((HitCircle)beatmap.HitObjects[1]).ComboIndex, Is.EqualTo(2)); + Assert.That(((HitCircle)beatmap.HitObjects[2]).ComboIndex, Is.EqualTo(3)); + }); + } + + [Test] + public void TestAutomaticallyInsertedBreakForcesNewCombo() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + Difficulty = + { + ApproachRate = 10, + }, + HitObjects = + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 5000 }, + }, + }); + + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(((HitCircle)beatmap.HitObjects[1]).NewCombo, Is.True); + + Assert.That(((HitCircle)beatmap.HitObjects[0]).ComboIndex, Is.EqualTo(1)); + Assert.That(((HitCircle)beatmap.HitObjects[1]).ComboIndex, Is.EqualTo(2)); + }); + } } } diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 0f8583253b..c081671a48 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -3,15 +3,14 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; 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.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; @@ -26,33 +25,34 @@ namespace osu.Game.Tests.Editing public partial class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene { private TestHitObjectComposer composer = null!; - - [Cached(typeof(EditorBeatmap))] - [Cached(typeof(IBeatSnapProvider))] - private readonly EditorBeatmap editorBeatmap; - - protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both }; - - public TestSceneHitObjectComposerDistanceSnapping() - { - base.Content.Add(new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - editorBeatmap = new EditorBeatmap(new OsuBeatmap - { - BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, - }), - Content - }, - }); - } + private EditorBeatmap editorBeatmap = null!; [SetUp] public void Setup() => Schedule(() => { - Child = composer = new TestHitObjectComposer(); + editorBeatmap = new EditorBeatmap(new OsuBeatmap + { + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + }); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(EditorBeatmap), editorBeatmap), + (typeof(IBeatSnapProvider), editorBeatmap) + ], + Children = new Drawable[] + { + editorBeatmap, + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = composer = new TestHitObjectComposer() + } + } + }; BeatDivisor.Value = 1; @@ -67,17 +67,7 @@ namespace osu.Game.Tests.Editing { AddStep($"set slider multiplier = {multiplier}", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = multiplier); - assertSnapDistance(100 * multiplier, null, true); - } - - [TestCase(1)] - [TestCase(2)] - public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier) - { - assertSnapDistance(100, new Slider - { - SliderVelocityMultiplier = multiplier - }, false); + assertSnapDistance(100 * multiplier); } [TestCase(1)] @@ -87,7 +77,7 @@ namespace osu.Game.Tests.Editing assertSnapDistance(100 * multiplier, new Slider { SliderVelocityMultiplier = multiplier - }, true); + }); } [TestCase(1)] @@ -96,7 +86,7 @@ namespace osu.Game.Tests.Editing { AddStep($"set divisor = {divisor}", () => BeatDivisor.Value = divisor); - assertSnapDistance(100f / divisor, null, true); + assertSnapDistance(100f / divisor); } /// @@ -114,9 +104,8 @@ namespace osu.Game.Tests.Editing }; AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject)); - assertSnapDistance(base_distance * slider_velocity, referenceObject, true); + assertSnapDistance(base_distance * slider_velocity, referenceObject); assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject); - assertSnappedDuration(base_distance * slider_velocity + 10, 1000, referenceObject); assertDistanceToDuration(base_distance * slider_velocity, 1000, referenceObject); assertDurationToDistance(1000, base_distance * slider_velocity, referenceObject); @@ -164,39 +153,6 @@ namespace osu.Game.Tests.Editing assertDistanceToDuration(400, 1000); } - [Test] - public void TestGetSnappedDurationFromDistance() - { - assertSnappedDuration(0, 0); - assertSnappedDuration(50, 1000); - assertSnappedDuration(100, 1000); - assertSnappedDuration(150, 2000); - assertSnappedDuration(200, 2000); - assertSnappedDuration(250, 3000); - - AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); - - assertSnappedDuration(0, 0); - assertSnappedDuration(50, 0); - assertSnappedDuration(100, 1000); - assertSnappedDuration(150, 1000); - assertSnappedDuration(200, 1000); - assertSnappedDuration(250, 1000); - - AddStep("set beat length = 500", () => - { - composer.EditorBeatmap.ControlPointInfo.Clear(); - composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); - }); - - assertSnappedDuration(50, 0); - assertSnappedDuration(100, 500); - assertSnappedDuration(150, 500); - assertSnappedDuration(200, 500); - assertSnappedDuration(250, 500); - assertSnappedDuration(400, 1000); - } - [Test] public void GetSnappedDistanceFromDistance() { @@ -289,20 +245,24 @@ namespace osu.Game.Tests.Editing AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False); } - private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) - => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistanceAt(referenceObject ?? new HitObject(), includeSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + private void assertSnapDistance(float expectedDistance, IHasSliderVelocity? hasSliderVelocity = null) + => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistance(hasSliderVelocity), + () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDurationToDistance(double duration, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DistanceSnapProvider.DurationToDistance(referenceObject ?? new HitObject(), duration), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"duration = {duration} -> distance = {expectedDistance}", + () => composer.DistanceSnapProvider.DurationToDistance(duration, referenceObject?.StartTime ?? 0, referenceObject as IHasSliderVelocity), + () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDistanceToDuration(float distance, double expectedDuration, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceSnapProvider.DistanceToDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); - - private void assertSnappedDuration(float distance, double expectedDuration, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> duration = {expectedDuration}", + () => composer.DistanceSnapProvider.DistanceToDuration(distance, referenceObject?.StartTime ?? 0, referenceObject as IHasSliderVelocity), + () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", + () => composer.DistanceSnapProvider.FindSnappedDistance(distance, referenceObject?.GetEndTime() ?? 0, referenceObject as IHasSliderVelocity), + () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private partial class TestHitObjectComposer : OsuHitObjectComposer { diff --git a/osu.Game.Tests/Extensions/NumberFormattingExtensionsTest.cs b/osu.Game.Tests/Extensions/NumberFormattingExtensionsTest.cs new file mode 100644 index 0000000000..f60c978788 --- /dev/null +++ b/osu.Game.Tests/Extensions/NumberFormattingExtensionsTest.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Extensions; + +namespace osu.Game.Tests.Extensions +{ + [TestFixture] + public class NumberFormattingExtensionsTest + { + [TestCase(-1, false, 0, ExpectedResult = "-1")] + [TestCase(0, false, 0, ExpectedResult = "0")] + [TestCase(1, false, 0, ExpectedResult = "1")] + [TestCase(500, false, 10, ExpectedResult = "500")] + [TestCase(-1, true, 0, ExpectedResult = "-1%")] + [TestCase(0, true, 0, ExpectedResult = "0%")] + [TestCase(1, true, 0, ExpectedResult = "1%")] + [TestCase(50, true, 0, ExpectedResult = "50%")] + [SetCulture("")] // invariant culture + public string TestInteger(int input, bool percent, int decimalDigits) + { + return input.ToStandardFormattedString(decimalDigits, percent); + } + + [TestCase(-1, false, 0, ExpectedResult = "-1")] + [TestCase(-1e-6, false, 0, ExpectedResult = "0")] + [TestCase(-1e-6, false, 6, ExpectedResult = "-0.000001")] + [TestCase(0, false, 10, ExpectedResult = "0")] + [TestCase(0, false, 0, ExpectedResult = "0")] + [TestCase(double.NegativeZero, false, 0, ExpectedResult = "0")] + [TestCase(1e-6, false, 0, ExpectedResult = "0")] + [TestCase(1e-6, false, 6, ExpectedResult = "0.000001")] + [TestCase(1, false, 0, ExpectedResult = "1")] + [TestCase(1.528, false, 2, ExpectedResult = "1.53")] + [TestCase(500, false, 10, ExpectedResult = "500")] + [TestCase(-0.1, true, 0, ExpectedResult = "-10%")] + [TestCase(0, true, 0, ExpectedResult = "0%")] + [TestCase(0.4, true, 0, ExpectedResult = "40%")] + [TestCase(0.48333, true, 2, ExpectedResult = "48%")] + [TestCase(0.48333, true, 4, ExpectedResult = "48.33%")] + [TestCase(1, true, 0, ExpectedResult = "100%")] + [SetCulture("")] // invariant culture + public string TestDouble(double input, bool percent, int decimalDigits) + { + return input.ToStandardFormattedString(decimalDigits, percent); + } + + [Test] + [SetCulture("fr-FR")] + [TestCase(0.4, true, 2, ExpectedResult = "40%")] + [TestCase(1e-6, false, 6, ExpectedResult = "0,000001")] + [TestCase(0.48333, true, 4, ExpectedResult = "48,33%")] + public string TestCultureSensitivity(double input, bool percent, int decimalDigits) + { + return input.ToStandardFormattedString(decimalDigits, percent); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs index 584a9e09c0..18030d7222 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -359,7 +359,7 @@ namespace osu.Game.Tests.Gameplay } public override Judgement CreateJudgement() => new TestJudgement(maxResult); - protected override HitWindows CreateHitWindows() => new HitWindows(); + protected override HitWindows CreateHitWindows() => new DefaultHitWindows(); private class TestJudgement : Judgement { diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs index d198ef5074..20d63b9bb4 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -64,11 +64,9 @@ namespace osu.Game.Tests.Gameplay /// /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the beatmap skin: /// normal-hitnormal2 - /// normal-hitnormal /// hitnormal /// [TestCase("normal-hitnormal2")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestDefaultCustomSampleFromBeatmap(string expectedSample) { @@ -128,6 +126,22 @@ namespace osu.Game.Tests.Gameplay AssertBeatmapLookup(expected_sample); } + /// + /// Tests that a hitobject which specifies a specific sample file which doesn't exist (or isn't allowed to be looked up) + /// falls back to a normal sample. + /// + [Test] + public void TestFileSampleFallsBackToNormal() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(null, expected_sample); + + CreateTestWithBeatmap("file-beatmap-sample.osu"); + + AssertUserLookup(expected_sample); + } + /// /// Tests that a default hitobject and control point causes . /// @@ -162,7 +176,6 @@ namespace osu.Game.Tests.Gameplay /// Tests that a control point that provides a custom sample of 2 causes . /// [TestCase("normal-hitnormal2")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestControlPointCustomSampleFromBeatmap(string sampleName) { diff --git a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs index e31a3dbdf0..2230763984 100644 --- a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs +++ b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs @@ -166,11 +166,7 @@ namespace osu.Game.Tests.Mods /// 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; } diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index decb0a31ac..6ec4e799e6 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -2,13 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using Moq; using NUnit.Framework; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Localisation; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Utils; @@ -181,98 +188,6 @@ namespace osu.Game.Tests.Mods }, }; - private static readonly object[] invalid_multiplayer_mod_test_scenarios = - { - // incompatible pair. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() }, - new[] { typeof(OsuModHidden), typeof(OsuModApproachDifferent) } - }, - // incompatible pair with derived class. - new object[] - { - new Mod[] { new OsuModDeflate(), new OsuModApproachDifferent() }, - new[] { typeof(OsuModDeflate), typeof(OsuModApproachDifferent) } - }, - // system mod. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, - new[] { typeof(OsuModTouchDevice) } - }, - // multi mod. - new object[] - { - new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, - new[] { typeof(MultiMod) } - }, - // invalid multiplayer mod. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() }, - new[] { typeof(InvalidMultiplayerMod) } - }, - // invalid free mod is valid for multiplayer global. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - Array.Empty() - }, - // valid pair. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - Array.Empty() - }, - }; - - private static readonly object[] invalid_free_mod_test_scenarios = - { - // system mod. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, - new[] { typeof(OsuModTouchDevice) } - }, - // multi mod. - new object[] - { - new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, - new[] { typeof(MultiMod) } - }, - // invalid multiplayer mod. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() }, - new[] { typeof(InvalidMultiplayerMod) } - }, - // invalid free mod. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - new[] { typeof(InvalidMultiplayerFreeMod) } - }, - // incompatible pair is valid for free mods. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() }, - Array.Empty(), - }, - // incompatible pair with derived class is valid for free mods. - new object[] - { - new Mod[] { new OsuModDeflate(), new OsuModSpinIn() }, - Array.Empty(), - }, - // valid pair. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - Array.Empty() - }, - }; - [TestCaseSource(nameof(invalid_mod_test_scenarios))] public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) { @@ -286,32 +201,6 @@ namespace osu.Game.Tests.Mods Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } - [TestCaseSource(nameof(invalid_multiplayer_mod_test_scenarios))] - public void TestInvalidMultiplayerModScenarios(Mod[] inputMods, Type[] expectedInvalid) - { - bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, out var invalid); - - Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); - - if (isValid) - Assert.IsNull(invalid); - else - Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); - } - - [TestCaseSource(nameof(invalid_free_mod_test_scenarios))] - public void TestInvalidFreeModScenarios(Mod[] inputMods, Type[] expectedInvalid) - { - bool isValid = ModUtils.CheckValidFreeModsForMultiplayer(inputMods, out var invalid); - - Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); - - if (isValid) - Assert.IsNull(invalid); - else - Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); - } - [Test] public void TestModBelongsToRuleset() { @@ -342,6 +231,163 @@ namespace osu.Game.Tests.Mods Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x"); } + private static readonly object[] multiplayer_mod_test_scenarios = + { + // valid - as allowed mod. + new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []), + new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []), + // valid - as allowed mod (incompatible pair). + new MultiplayerTestScenario(false, false, [new OsuModHardRock(), new OsuModEasy()], []), + new MultiplayerTestScenario(false, true, [new OsuModHardRock(), new OsuModEasy()], []), + // valid - as allowed mod (incompatible pair with derived classes). + new MultiplayerTestScenario(false, false, [new OsuModDeflate(), new OsuModApproachDifferent()], []), + new MultiplayerTestScenario(false, true, [new OsuModDeflate(), new OsuModApproachDifferent()], []), + // valid - as allowed mod (not implemented in all rulesets). + new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []), + new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []), + // valid - as required mod. + new MultiplayerTestScenario(true, false, [new OsuModStrictTracking()], []), + // valid - as required mod when not freestyle. + new MultiplayerTestScenario(true, false, [new InvalidFreestyleRequiredMod()], []), + // valid - as required mod when freestyle (implemented in all rulesets). + new MultiplayerTestScenario(true, true, [new OsuModEasy()], []), + new MultiplayerTestScenario(true, true, [new OsuModNoFail()], []), + new MultiplayerTestScenario(true, true, [new OsuModHalfTime()], []), + new MultiplayerTestScenario(true, true, [new OsuModDaycore()], []), + new MultiplayerTestScenario(true, true, [new OsuModHardRock()], []), + new MultiplayerTestScenario(true, true, [new OsuModSuddenDeath()], []), + new MultiplayerTestScenario(true, true, [new OsuModPerfect()], []), + new MultiplayerTestScenario(true, true, [new OsuModDoubleTime()], []), + new MultiplayerTestScenario(true, true, [new OsuModNightcore()], []), + new MultiplayerTestScenario(true, true, [new OsuModDifficultyAdjust()], []), + new MultiplayerTestScenario(true, true, [new ModWindUp()], []), + new MultiplayerTestScenario(true, true, [new ModWindDown()], []), + new MultiplayerTestScenario(true, true, [new OsuModMuted()], []), + + // invalid - always (system mod) + new MultiplayerTestScenario(false, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]), + new MultiplayerTestScenario(true, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]), + // invalid - always (multi mod). + new MultiplayerTestScenario(false, false, [new MultiMod()], [typeof(MultiMod)]), + new MultiplayerTestScenario(true, false, [new MultiMod()], [typeof(MultiMod)]), + // invalid - always (disallowed by mod) + new MultiplayerTestScenario(false, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]), + new MultiplayerTestScenario(true, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]), + new MultiplayerTestScenario(false, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]), + new MultiplayerTestScenario(true, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]), + // invalid - always (changes play length - for now not allowed in multiplayer). + new MultiplayerTestScenario(false, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]), + new MultiplayerTestScenario(true, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]), + // invalid - as allowed mod (disallowed by mod). + new MultiplayerTestScenario(false, false, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]), + new MultiplayerTestScenario(false, true, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]), + // invalid - as allowed mod (changes play length - for now not allowed in multiplayer). + new MultiplayerTestScenario(false, false, [new OsuModHalfTime()], [typeof(OsuModHalfTime)]), + new MultiplayerTestScenario(false, false, [new OsuModDaycore()], [typeof(OsuModDaycore)]), + new MultiplayerTestScenario(false, false, [new OsuModDoubleTime()], [typeof(OsuModDoubleTime)]), + new MultiplayerTestScenario(false, false, [new OsuModNightcore()], [typeof(OsuModNightcore)]), + // invalid - as required mod (incompatible pair) + new MultiplayerTestScenario(true, false, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]), + new MultiplayerTestScenario(true, true, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]), + new MultiplayerTestScenario(true, false, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]), + new MultiplayerTestScenario(true, true, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]), + // invalid - as required mod when freestyle (disallowed by mod). + new MultiplayerTestScenario(true, true, [new InvalidFreestyleRequiredMod()], [typeof(InvalidFreestyleRequiredMod)]), + // invalid - as required mod when freestyle (not implemented in all rulesets). + new MultiplayerTestScenario(true, true, [new OsuModStrictTracking()], [typeof(OsuModStrictTracking)]), + new MultiplayerTestScenario(true, true, [new OsuModBarrelRoll()], [typeof(OsuModBarrelRoll)]), + }; + + [TestCaseSource(nameof(multiplayer_mod_test_scenarios))] + public void TestMultiplayerModScenarios(MultiplayerTestScenario scenario) + { + List? invalidMods; + bool isValid = scenario.IsRequired + ? ModUtils.CheckValidRequiredModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods) + : ModUtils.CheckValidAllowedModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods); + + Assert.That(isValid, Is.EqualTo(scenario.InvalidTypes.Length == 0)); + + if (isValid) + Assert.IsNull(invalidMods); + else + Assert.That(invalidMods?.Select(t => t.GetType()), Is.EquivalentTo(scenario.InvalidTypes)); + } + + [Test] + public void TestPlaylistsModScenarios() + { + // The rest are tested by TestMultiplayerModScenarios. + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), false, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), true, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), false, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), true, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), false, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), true, MatchType.Playlists, false)); + } + + [Test] + public void TestFreestyleRulesetCompatibility() + { + HashSet commonAcronyms = new HashSet(); + + commonAcronyms.UnionWith(new OsuRuleset().CreateAllMods().Select(m => m.Acronym)); + commonAcronyms.IntersectWith(new TaikoRuleset().CreateAllMods().Select(m => m.Acronym)); + commonAcronyms.IntersectWith(new CatchRuleset().CreateAllMods().Select(m => m.Acronym)); + commonAcronyms.IntersectWith(new ManiaRuleset().CreateAllMods().Select(m => m.Acronym)); + + Assert.Multiple(() => + { + foreach (var ruleset in new Ruleset[] { new OsuRuleset(), new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset() }) + { + foreach (var mod in ruleset.CreateAllMods()) + { + if (mod.ValidForFreestyleAsRequiredMod && !mod.UserPlayable) + Assert.Fail($"Mod {mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but is not playable!"); + + if (mod.ValidForFreestyleAsRequiredMod && !mod.HasImplementation) + Assert.Fail($"Mod {mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but is not implemented!"); + + if (mod.ValidForFreestyleAsRequiredMod && mod.UserPlayable && mod.HasImplementation && !commonAcronyms.Contains(mod.Acronym)) + Assert.Fail($"{mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but does not exist in all four basic rulesets!"); + } + } + }); + } + + [Test] + public void TestModsValidForRequiredFreestyleAreConsistentlyCompatibleAcrossRulesets() + { + Dictionary<(string firstMod, string secondMod), bool> compatibilityMap = new Dictionary<(string, string), bool>(); + + Assert.Multiple(() => + { + for (int rulesetId = 0; rulesetId < 4; ++rulesetId) + { + var rulesetStore = new AssemblyRulesetStore(); + var ruleset = rulesetStore.GetRuleset(rulesetId)!.CreateInstance(); + + var modsValidForFreestyleAsRequired = ruleset.CreateAllMods().Where(m => m.ValidForFreestyleAsRequiredMod).OrderBy(m => m.Acronym).ToList(); + + for (int i = 0; i < modsValidForFreestyleAsRequired.Count; i++) + { + for (int j = i; j < modsValidForFreestyleAsRequired.Count; ++j) + { + var first = modsValidForFreestyleAsRequired[i]; + var second = modsValidForFreestyleAsRequired[j]; + + bool compatible = ModUtils.CheckCompatibleSet([first, second]); + + if (!compatibilityMap.TryGetValue((first.Acronym, second.Acronym), out bool previousCompatible)) + compatibilityMap[(first.Acronym, second.Acronym)] = compatible; + else if (previousCompatible != compatible) + Assert.Fail($"{first.Acronym} and {second.Acronym} declare {nameof(Mod.ValidForFreestyleAsRequiredMod)} while not being consistently compatible in all four rulesets!"); + } + } + } + }); + } + public abstract class CustomMod1 : Mod, IModCompatibilitySpecification { } @@ -350,7 +396,7 @@ namespace osu.Game.Tests.Mods { } - public class InvalidMultiplayerMod : Mod + private class InvalidMultiplayerMod : Mod { public override string Name => string.Empty; public override LocalisableString Description => string.Empty; @@ -371,18 +417,22 @@ namespace osu.Game.Tests.Mods public override bool ValidForMultiplayerAsFreeMod => false; } - public class EditableMod : Mod + public class InvalidFreestyleRequiredMod : Mod { public override string Name => string.Empty; public override LocalisableString Description => string.Empty; + public override double ScoreMultiplier => 1; public override string Acronym => string.Empty; - public override double ScoreMultiplier => Multiplier; - - public double Multiplier = 1; + public override bool HasImplementation => true; + public override bool ValidForFreestyleAsRequiredMod => false; } - public interface IModCompatibilitySpecification + public interface IModCompatibilitySpecification; + + public readonly record struct MultiplayerTestScenario(bool IsRequired, bool IsFreestyle, Mod[] Mods, Type[] InvalidTypes) { + public override string ToString() + => $"{IsRequired}, {IsFreestyle}, [{string.Join(',', Mods.Select(m => m.GetType().ReadableName()))}], [{string.Join(',', InvalidTypes.Select(t => t.ReadableName()))}]"; } } } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 1efcc8542d..12aab055ad 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.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; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -40,6 +42,11 @@ namespace osu.Game.Tests.NonVisual.Filtering Author = { Username = "The Author" }, Source = "unit tests", Tags = "look for tags too", + UserTags = + { + "song representation/simple", + "style/clean", + } }, DifficultyName = "version as well", Length = 2500, @@ -292,6 +299,121 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(filtered, carouselItem.Filtered.Value); } + [Test] + [TestCase("artist")] + [TestCase("unicode")] + public void TestCriteriaNotMatchingArtist(string excludedTerm) + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = excludedTerm, ExcludeTerm = true } + }; + + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.True(carouselItem.Filtered.Value); + } + + [TestCase("simple", false)] + [TestCase("\"style/clean\"", false)] + [TestCase("\"style/clean\"!", false)] + [TestCase("iNiS-style", true)] + [TestCase("\"reading/visually dense\"!", true)] + public void TestCriteriaMatchingUserTags(string query, bool filtered) + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria { UserTags = [new FilterCriteria.OptionalTextFilter { SearchTerm = query }] }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } + + [Test] + public void TestCriteriaMatchingMultipleTagsAtOnce() + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria + { + UserTags = + [ + new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!" }, + new FilterCriteria.OptionalTextFilter { SearchTerm = "\"style/clean\"!" } + ] + }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(false, carouselItem.Filtered.Value); + } + + [Test] + public void TestCriteriaAllTagFiltersMustMatch() + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria + { + UserTags = + [ + new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!" }, + new FilterCriteria.OptionalTextFilter { SearchTerm = "\"style/dirty\"!" } + ] + }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(true, carouselItem.Filtered.Value); + } + + [Test] + public void TestCriteriaMatchingTagExcluded() + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria + { + UserTags = + [ + new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!", ExcludeTerm = true }, + ] + }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(true, carouselItem.Filtered.Value); + } + + [Test] + public void TestCriteriaOneTagIncludedAndOneTagExcluded() + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria + { + UserTags = + [ + new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!" }, + new FilterCriteria.OptionalTextFilter { SearchTerm = "\"style/clean\"!", ExcludeTerm = true } + ] + }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(true, carouselItem.Filtered.Value); + } + + [Test] + public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive() + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria { UserTags = [new FilterCriteria.OptionalTextFilter { SearchTerm = "simple" }] }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.BeatmapInfo.Metadata.UserTags.Clear(); + carouselItem.Filter(criteria); + + Assert.True(carouselItem.Filtered.Value); + } + [Test] public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCustomCriteria) { @@ -305,6 +427,167 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(matchCustomCriteria == false, carouselItem.Filtered.Value); } + [TestCase("title!=Title", new[] { 2, 4, 6 })] + [TestCase("title!=\"Title1\"", new[] { 2, 3, 4, 5, 6 })] + [TestCase("title!=\"Title1\"!", new[] { 2, 3, 4, 5, 6 })] + public void TestNotEqualSearchForTextFilters(string query, int[] expectedBeatmapIndexes) + { + string[] titles = + [ + "Title1", + "Title1", + "My[Favourite]Song", + "Title", + "Another One", + "Diff in title", + "a", + ]; + + var carouselBeatmaps = titles.Select(title => new CarouselBeatmap(new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = title, + }, + })).ToList(); + + var criteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + [Test] + public void TestNotEqualSearchForNumberFilters() + { + double[] starRatings = + [ + 2.78, + 1.78, + 1.55, + 3.78, + 1.78, + 1.55, + 2.78 + ]; + + var carouselBeatmaps = starRatings.Select(starRating => new CarouselBeatmap(new BeatmapInfo + { + StarRating = starRating, + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, "star!=1.78"); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(new[] { 0, 2, 3, 5, 6 })); + } + + [TestCase("status!=ranked", new[] { 1, 2, 4, 5 })] + [TestCase("status!=r", new[] { 1, 2, 4, 5 })] + [TestCase("status!=loved", new[] { 0, 1, 2, 3, 4, 6 })] + [TestCase("status!=l", new[] { 0, 1, 2, 3, 4, 6 })] + [TestCase("status!=r,l", new[] { 1, 2, 4 })] + public void TestNotEqualSearchForEnumFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = new[] + { + BeatmapOnlineStatus.Ranked, + BeatmapOnlineStatus.Qualified, + BeatmapOnlineStatus.Approved, + BeatmapOnlineStatus.Ranked, + BeatmapOnlineStatus.Approved, + BeatmapOnlineStatus.Loved, + BeatmapOnlineStatus.Ranked + }.Select(info => new CarouselBeatmap(new BeatmapInfo + { + Status = info + })).ToList(); + + var criteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + [TestCase("played!=1", new[] { 1, 4, 5 })] + [TestCase("played!=0", new[] { 0, 2, 3, 6, 7 })] + public void TestNotEqualSearchForBooleanFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = (new DateTimeOffset?[] + { + new DateTimeOffset(2012, 10, 21, 12, 13, 24, TimeSpan.Zero), + null, + new DateTimeOffset(2012, 11, 12, 23, 10, 13, TimeSpan.Zero), + new DateTimeOffset(2013, 2, 13, 11, 43, 23, TimeSpan.Zero), + null, + null, + new DateTimeOffset(2014, 1, 15, 20, 13, 24, TimeSpan.Zero), + new DateTimeOffset(2014, 11, 16, 0, 13, 23, TimeSpan.Zero), + }).Select(lastPlayed => new CarouselBeatmap(new BeatmapInfo + { + LastPlayed = lastPlayed + })).ToList(); + + var criteria = new FilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + + [TestCase("ranked!=2012", new[] { 3, 4, 5, 6, 7 })] + [TestCase("ranked!=2012.11", new[] { 0, 1, 3, 4, 5, 6, 7 })] + [TestCase("ranked!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })] + public void TestNotEqualSearchForDateFilter(string query, int[] expectedBeatmapIndexes) + { + var carouselBeatmaps = new[] + { + new DateTimeOffset(2012, 10, 21, 13, 42, 13, TimeSpan.Zero), + new DateTimeOffset(2012, 10, 11, 2, 33, 43, TimeSpan.Zero), + new DateTimeOffset(2012, 11, 12, 10, 22, 32, TimeSpan.Zero), + new DateTimeOffset(2013, 2, 13, 5, 19, 0, TimeSpan.Zero), + new DateTimeOffset(2013, 2, 13, 11, 23, 35, TimeSpan.Zero), + new DateTimeOffset(2013, 3, 14, 9, 9, 1, TimeSpan.Zero), + new DateTimeOffset(2014, 1, 15, 10, 5, 0, TimeSpan.Zero), + new DateTimeOffset(2014, 11, 16, 23, 27, 0, TimeSpan.Zero), + }.Select(dateRanked => new CarouselBeatmap(new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo + { + DateRanked = dateRanked, + } + })).ToList(); + var criteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); + carouselBeatmaps.ForEach(b => b.Filter(criteria)); + + int[] visibleBeatmaps = carouselBeatmaps + .Where(b => !b.Filtered.Value) + .Select(b => carouselBeatmaps.IndexOf(b)).ToArray(); + + Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); + } + private class CustomCriteria : IRulesetFilterCriteria { private readonly bool match; diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index f4e324d7ba..8bef6b04a7 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -85,16 +85,6 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); } - /* - * The following tests have been written a bit strangely (they don't check exact - * bound equality with what the filter says). - * This is to account for floating-point arithmetic issues. - * For example, specifying a bpm<140 filter would previously match beatmaps with BPM - * of 139.99999, which would be displayed in the UI as 140. - * Due to this the tests check the last tick inside the range and the first tick - * outside of the range. - */ - [TestCase("star")] [TestCase("stars")] public void TestApplyStarQueries(string variant) @@ -105,11 +95,31 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); Assert.AreEqual(1, filterCriteria.SearchTerms.Length); Assert.IsNotNull(filterCriteria.StarDifficulty.Max); - Assert.Greater(filterCriteria.StarDifficulty.Max, 3.99d); - Assert.Less(filterCriteria.StarDifficulty.Max, 4.00d); + Assert.AreEqual(filterCriteria.StarDifficulty.Max, 4.00d); Assert.IsNull(filterCriteria.StarDifficulty.Min); } + [Test] + public void TestStarQueriesInclusive() + { + const string query = "stars>=6"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(filterCriteria.StarDifficulty.Min, 6.00d); + Assert.True(filterCriteria.StarDifficulty.IsLowerInclusive); + Assert.IsNull(filterCriteria.StarDifficulty.Max); + } + + /* + * The following tests have been written a bit strangely (they don't check exact + * bound equality with what the filter says). + * This is to account for floating-point arithmetic issues. + * For example, specifying a bpm<140 filter would previously match beatmaps with BPM + * of 139.99999, which would be displayed in the UI as 140. + * Due to this the tests check the last tick inside the range and the first tick + * outside of the range. + */ + [Test] public void TestApplyApproachRateQueries() { @@ -168,6 +178,16 @@ namespace osu.Game.Tests.NonVisual.Filtering [Test] public void TestApplyBPMQueries() + { + const string query = "bpm=200"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(filterCriteria.BPM.Min, 199.5d); + Assert.AreEqual(filterCriteria.BPM.Max, 200.5d); + } + + [Test] + public void TestApplyBPMRangeQueries() { const string query = "bpm>:200 gotta go fast"; var filterCriteria = new FilterCriteria(); @@ -175,8 +195,7 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim()); Assert.AreEqual(3, filterCriteria.SearchTerms.Length); Assert.IsNotNull(filterCriteria.BPM.Min); - Assert.Greater(filterCriteria.BPM.Min, 199.99d); - Assert.Less(filterCriteria.BPM.Min, 200.00d); + Assert.AreEqual(filterCriteria.BPM.Min, 199.5d); Assert.IsNull(filterCriteria.BPM.Max); } @@ -284,6 +303,16 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(filterCriteria.OnlineStatus.Values, Is.Empty); } + [Test] + public void TestPartialStatusNotMatch() + { + const string query = "status!=r"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.IsNotEmpty(filterCriteria.OnlineStatus.Values); + Assert.That(filterCriteria.OnlineStatus.Values, Does.Not.Contain(BeatmapOnlineStatus.Ranked)); + } + [Test] public void TestApplyEqualStatusQueryWithMultipleValues() { @@ -746,5 +775,17 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); Assert.AreEqual(matched, filterCriteria.LastPlayed.IsInRange(reference)); } + + [Test] + public void TestMultipleTextFilters() + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, "tag=\"simple\" tag=\"clean\"!"); + Assert.That(filterCriteria.UserTags, Has.Count.EqualTo(2)); + Assert.That(filterCriteria.UserTags[0].SearchTerm, Is.EqualTo("simple")); + Assert.That(filterCriteria.UserTags[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); + Assert.That(filterCriteria.UserTags[1].SearchTerm, Is.EqualTo("clean")); + Assert.That(filterCriteria.UserTags[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase)); + } } } diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs index 07d6d68e82..69c98351ad 100644 --- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -33,7 +33,7 @@ namespace osu.Game.Tests.NonVisual public void TestResultIfOnlyParentHitWindowIsEmpty() { var testObject = new TestHitObject(HitWindows.Empty); - HitObject nested = new TestHitObject(new HitWindows()); + HitObject nested = new TestHitObject(new DefaultHitWindows()); testObject.AddNested(nested); testDrawableRuleset.HitObjects = new List { testObject }; @@ -43,8 +43,8 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestResultIfParentHitWindowsIsNotEmpty() { - var testObject = new TestHitObject(new HitWindows()); - HitObject nested = new TestHitObject(new HitWindows()); + var testObject = new TestHitObject(new DefaultHitWindows()); + HitObject nested = new TestHitObject(new DefaultHitWindows()); testObject.AddNested(nested); testDrawableRuleset.HitObjects = new List { testObject }; @@ -58,7 +58,7 @@ namespace osu.Game.Tests.NonVisual HitObject nested = new TestHitObject(HitWindows.Empty); firstObject.AddNested(nested); - var secondObject = new TestHitObject(new HitWindows()); + var secondObject = new TestHitObject(new DefaultHitWindows()); testDrawableRuleset.HitObjects = new List { firstObject, secondObject }; Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, secondObject.HitWindows); @@ -101,7 +101,6 @@ namespace osu.Game.Tests.NonVisual public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } internal override bool FrameStablePlayback { get; set; } - public override bool AllowBackwardsSeeks { get; set; } public override IReadOnlyList Mods { get; } public override double GameplayStartTime { get; } diff --git a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs index a12658bd8b..0fcf754cf6 100644 --- a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs +++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs @@ -21,5 +21,18 @@ namespace osu.Game.Tests.NonVisual { Assert.AreEqual(expectedOutput, input.FormatAccuracy().ToString()); } + + [TestCase(3, "3.00")] + [TestCase(3.3, "3.30")] + [TestCase(3.55, "3.55")] + [TestCase(3.553, "3.55")] + [TestCase(3.557, "3.55")] + [TestCase(3.9999, "3.99")] + [TestCase(3.999999, "3.99")] + [TestCase(4, "4.00")] + public void TestStarRatingFormatting(double input, string expectedOutput) + { + Assert.AreEqual(expectedOutput, input.FormatStarRating().ToString()); + } } } diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index 541ad1e8bb..ffb21f124c 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -383,6 +383,9 @@ namespace osu.Game.Tests.NonVisual IsImportant = isImportant; FrameIndex = frameIndex; } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is TestReplayFrame testFrame && Time == testFrame.Time && IsImportant == testFrame.IsImportant && FrameIndex == testFrame.FrameIndex; } private class TestInputHandler : FramedReplayInputHandler diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index 559db16751..8364e58bdc 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -6,9 +6,9 @@ using Humanizer; using NUnit.Framework; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; +using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; using osu.Game.Tests.Visual.Multiplayer; namespace osu.Game.Tests.NonVisual.Multiplayer @@ -16,6 +16,13 @@ namespace osu.Game.Tests.NonVisual.Multiplayer [HeadlessTest] public partial class StatefulMultiplayerClientTest : MultiplayerTestScene { + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + } + [Test] public void TestUserAddedOnJoin() { @@ -72,10 +79,6 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddStep("create room initially in gameplay", () => { - var newRoom = new Room(); - newRoom.CopyFrom(SelectedRoom.Value!); - - newRoom.RoomID = null; MultiplayerClient.RoomSetupAction = room => { room.State = MultiplayerRoomState.Playing; @@ -86,13 +89,32 @@ namespace osu.Game.Tests.NonVisual.Multiplayer }); }; - RoomManager.CreateRoom(newRoom); + MultiplayerClient.JoinRoom(MultiplayerClient.ServerSideRooms.Single()).ConfigureAwait(false); }); AddUntilStep("wait for room join", () => RoomJoined); checkPlayingUserCount(1); } + [Test] + public void TestJoinRoomWithManyUsers() + { + AddStep("leave room", () => MultiplayerClient.LeaveRoom()); + AddUntilStep("wait for room part", () => !RoomJoined); + + AddStep("create room with many users", () => + { + MultiplayerClient.RoomSetupAction = room => + { + room.Users.AddRange(Enumerable.Range(PLAYER_1_ID, 100).Select(id => new MultiplayerRoomUser(id))); + }; + + MultiplayerClient.JoinRoom(MultiplayerClient.ServerSideRooms.Single()).ConfigureAwait(false); + }); + + AddUntilStep("wait for room join", () => RoomJoined); + } + private void checkPlayingUserCount(int expectedCount) => AddAssert($"{"user".ToQuantity(expectedCount)} playing", () => MultiplayerClient.CurrentMatchPlayingUserIds.Count == expectedCount); diff --git a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs index 03dc91b5d4..18ac5b4964 100644 --- a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs +++ b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs @@ -36,6 +36,10 @@ namespace osu.Game.Tests.NonVisual.Ranking .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)) .ToList(); + // Add some red herrings + events.Insert(4, new HitEvent(200, 1.0, HitResult.Meh, new HitObject { HitWindows = HitWindows.Empty }, null, null)); + events.Insert(8, new HitEvent(-100, 1.0, HitResult.Miss, new HitObject(), null, null)); + HitEventExtensions.UnstableRateCalculationResult result = null; for (int i = 0; i < events.Count; i++) @@ -57,6 +61,10 @@ namespace osu.Game.Tests.NonVisual.Ranking .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)) .ToList(); + // Add some red herrings + events.Insert(4, new HitEvent(200, 1.0, HitResult.Meh, new HitObject { HitWindows = HitWindows.Empty }, null, null)); + events.Insert(8, new HitEvent(-100, 1.0, HitResult.Miss, new HitObject(), null, null)); + HitEventExtensions.UnstableRateCalculationResult result = null; for (int i = 0; i < events.Count; i++) diff --git a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs new file mode 100644 index 0000000000..e20fb50722 --- /dev/null +++ b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs @@ -0,0 +1,219 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual; +using osu.Game.Updater; + +namespace osu.Game.Tests.NonVisual +{ + [HeadlessTest] + public partial class TestSceneUpdateManager : OsuTestScene + { + [Cached(typeof(INotificationOverlay))] + private readonly INotificationOverlay notifications = new TestNotificationOverlay(); + + private TestUpdateManager manager = null!; + private OsuConfigManager config = null!; + + [SetUpSteps] + public void SetupSteps() + { + AddStep("add manager", () => + { + config = new OsuConfigManager(LocalStorage); + config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer); + + Child = new DependencyProvidingContainer + { + CachedDependencies = [(typeof(OsuConfigManager), config)], + Child = manager = new TestUpdateManager() + }; + }); + + // Updates should be checked when the object is loaded for the first time. + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("1 check completed", () => manager.Completions, () => Is.EqualTo(1)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + [TearDownSteps] + public void TeardownSteps() + { + // Importantly, this immediately saves the config, which cancels any pending background save. + AddStep("dispose config manager", () => config.Dispose()); + } + + /// + /// Updates should be checked when the release stream is changed. + /// + [Test] + public void TestReleaseStreamChanged() + { + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + AddUntilStep("no check pending", () => !manager.IsPending); + + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer)); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + /// + /// Changing the release stream should start a new invocation and cancel the existing one. + /// + [Test] + public void TestNewInvocationOnReleaseStreamChanged() + { + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); + AddUntilStep("check pending", () => manager.IsPending); + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer)); + AddUntilStep("3 invocations", () => manager.Invocations, () => Is.EqualTo(3)); + + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + /// + /// Updates should be checked when the user requests them to. + /// + [Test] + public void TestUserRequest() + { + AddStep("request check", () => manager.CheckForUpdate()); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + AddUntilStep("no check pending", () => !manager.IsPending); + + AddStep("request check", () => manager.CheckForUpdate()); + + AddUntilStep("check pending", () => manager.IsPending); + AddStep("complete check", () => manager.Complete()); + AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + /// + /// User requests should start a new invocation and cancel the existing one. + /// + [Test] + public void TestUserRequestOverridesExistingCheck() + { + // This part covering double user input is not really possible because the settings button is disabled during the check, + // but it's kept here for sanity in-case the update manager is used as a standalone object elsewhere. + + AddStep("request check", () => manager.CheckForUpdate()); + AddUntilStep("check pending", () => manager.IsPending); + AddStep("request check", () => manager.CheckForUpdate()); + AddUntilStep("3 invocations", () => manager.Invocations, () => Is.EqualTo(3)); + + AddStep("complete check", () => manager.Complete()); + AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2)); + AddUntilStep("no check pending", () => !manager.IsPending); + + // This next part tests for the user requesting an update during a background check, and is possible to occur in practice. + + AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon)); + AddUntilStep("check pending", () => manager.IsPending); + AddStep("request check", () => manager.CheckForUpdate()); + AddUntilStep("5 invocations", () => manager.Invocations, () => Is.EqualTo(5)); + + AddStep("complete check", () => manager.Complete()); + AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3)); + AddUntilStep("no check pending", () => !manager.IsPending); + } + + [Test] + public void TestFixedReleaseStreamWrittenToConfig() + { + AddStep("add manager", () => + { + config = new OsuConfigManager(LocalStorage); + config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer); + + Child = new DependencyProvidingContainer + { + CachedDependencies = [(typeof(OsuConfigManager), config)], + Child = manager = new TestUpdateManager(ReleaseStream.Tachyon) + }; + }); + + AddAssert("release stream set to tachyon", () => config.Get(OsuSetting.ReleaseStream), () => Is.EqualTo(ReleaseStream.Tachyon)); + } + + private partial class TestUpdateManager : UpdateManager + { + public override ReleaseStream? FixedReleaseStream { get; } + + public bool IsPending { get; private set; } + public int Invocations { get; private set; } + public int Completions { get; private set; } + + private TaskCompletionSource? pendingCheck; + + public TestUpdateManager(ReleaseStream? fixedReleaseStream = null) + { + FixedReleaseStream = fixedReleaseStream; + } + + protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) + { + Invocations++; + + var check = pendingCheck = new TaskCompletionSource(); + IsPending = true; + + try + { + bool result = await check.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + Completions++; + return result; + } + finally + { + IsPending = false; + } + } + + public void Complete() + { + pendingCheck?.SetResult(true); + } + } + + private partial class TestNotificationOverlay : INotificationOverlay + { + public void Post(Notification notification) + { + } + + public void Hide() + { + } + + public IBindable UnreadCount { get; } = new Bindable(); + + public IEnumerable AllNotifications { get; } = Enumerable.Empty(); + } + } +} diff --git a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs index e4118a23b4..a391ec4066 100644 --- a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs +++ b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs @@ -12,79 +12,79 @@ namespace osu.Game.Tests.Online.Chat [Test] public void TestContainsUsernameMidlinePositive() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test message", "Test")); + Assert.IsTrue(MessageNotifier.MatchUsername("This is a test message", "Test").Success); } [Test] public void TestContainsUsernameStartOfLinePositive() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test message", "Test")); + Assert.IsTrue(MessageNotifier.MatchUsername("Test message", "Test").Success); } [Test] public void TestContainsUsernameEndOfLinePositive() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test", "Test")); + Assert.IsTrue(MessageNotifier.MatchUsername("This is a test", "Test").Success); } [Test] public void TestContainsUsernameMidlineNegative() { - Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a testmessage for notifications", "Test")); + Assert.IsFalse(MessageNotifier.MatchUsername("This is a testmessage for notifications", "Test").Success); } [Test] public void TestContainsUsernameStartOfLineNegative() { - Assert.IsFalse(MessageNotifier.CheckContainsUsername("Testmessage", "Test")); + Assert.IsFalse(MessageNotifier.MatchUsername("Testmessage", "Test").Success); } [Test] public void TestContainsUsernameEndOfLineNegative() { - Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a notificationtest", "Test")); + Assert.IsFalse(MessageNotifier.MatchUsername("This is a notificationtest", "Test").Success); } [Test] public void TestContainsUsernameBetweenPunctuation() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("Hello 'test'-message", "Test")); + Assert.IsTrue(MessageNotifier.MatchUsername("Hello 'test'-message", "Test").Success); } [Test] public void TestContainsUsernameUnicode() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test \u0460\u0460 message", "\u0460\u0460")); + Assert.IsTrue(MessageNotifier.MatchUsername("Test \u0460\u0460 message", "\u0460\u0460").Success); } [Test] public void TestContainsUsernameUnicodeNegative() { - Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test ha\u0460\u0460o message", "\u0460\u0460")); + Assert.IsFalse(MessageNotifier.MatchUsername("Test ha\u0460\u0460o message", "\u0460\u0460").Success); } [Test] public void TestContainsUsernameSpecialCharactersPositive() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test [#^-^#] message", "[#^-^#]")); + Assert.IsTrue(MessageNotifier.MatchUsername("Test [#^-^#] message", "[#^-^#]").Success); } [Test] public void TestContainsUsernameSpecialCharactersNegative() { - Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test pad[#^-^#]oru message", "[#^-^#]")); + Assert.IsFalse(MessageNotifier.MatchUsername("Test pad[#^-^#]oru message", "[#^-^#]").Success); } [Test] public void TestContainsUsernameAtSign() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("@username hi", "username")); + Assert.IsTrue(MessageNotifier.MatchUsername("@username hi", "username").Success); } [Test] public void TestContainsUsernameColon() { - Assert.IsTrue(MessageNotifier.CheckContainsUsername("username: hi", "username")); + Assert.IsTrue(MessageNotifier.MatchUsername("username: hi", "username").Success); } } } diff --git a/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs b/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs new file mode 100644 index 0000000000..5f82d22ae8 --- /dev/null +++ b/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs @@ -0,0 +1,153 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; + +namespace osu.Game.Tests.Online.Matchmaking +{ + public class MatchmakingRoomStateTest + { + /// + /// The number of points awarded for each placement position (index 0 = #1, index 7 = #8). + /// + private static readonly int[] placement_points = [8, 7, 6, 5, 4, 3, 2, 1]; + + [Test] + public void Basic() + { + var state = new MatchmakingRoomState(); + + // 1 -> 3 -> 2 + + state.AdvanceRound(); + state.RecordScores( + [ + new SoloScoreInfo { UserID = 2, TotalScore = 500 }, + new SoloScoreInfo { UserID = 1, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 3, TotalScore = 750 }, + ], placement_points); + + Assert.AreEqual(8, state.Users.GetOrAdd(1).Points); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); + + Assert.AreEqual(6, state.Users.GetOrAdd(2).Points); + Assert.AreEqual(3, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(3, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); + + Assert.AreEqual(7, state.Users.GetOrAdd(3).Points); + Assert.AreEqual(2, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); + + // 2 -> 1 -> 3 + + state.AdvanceRound(); + state.RecordScores( + [ + new SoloScoreInfo { UserID = 2, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 1, TotalScore = 750 }, + new SoloScoreInfo { UserID = 3, TotalScore = 500 }, + ], placement_points); + + Assert.AreEqual(15, state.Users.GetOrAdd(1).Points); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(2).Placement); + + Assert.AreEqual(14, state.Users.GetOrAdd(2).Points); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(1, state.Users.GetOrAdd(2).Rounds.GetOrAdd(2).Placement); + + Assert.AreEqual(13, state.Users.GetOrAdd(3).Points); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Rounds.GetOrAdd(2).Placement); + } + + [Test] + public void MatchingScores() + { + var state = new MatchmakingRoomState(); + + // 1 + 2 -> 3 + 4 + + state.AdvanceRound(); + state.RecordScores( + [ + new SoloScoreInfo { UserID = 1, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 2, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 3, TotalScore = 500 }, + new SoloScoreInfo { UserID = 4, TotalScore = 500 }, + ], placement_points); + + Assert.AreEqual(7, state.Users.GetOrAdd(1).Points); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); + + Assert.AreEqual(7, state.Users.GetOrAdd(2).Points); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); + + Assert.AreEqual(5, state.Users.GetOrAdd(3).Points); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(4, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); + + Assert.AreEqual(5, state.Users.GetOrAdd(4).Points); + Assert.AreEqual(4, state.Users.GetOrAdd(4).Placement); + Assert.AreEqual(4, state.Users.GetOrAdd(4).Rounds.GetOrAdd(1).Placement); + } + + [Test] + public void RoundTieBreaker() + { + var state = new MatchmakingRoomState(); + + // 1 -> 2 + + state.AdvanceRound(); + state.RecordScores( + [ + new SoloScoreInfo { UserID = 1, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 2, TotalScore = 500 }, + ], placement_points); + + // 2 -> 1 + + state.AdvanceRound(); + state.RecordScores( + [ + new SoloScoreInfo { UserID = 1, TotalScore = 500 }, + new SoloScoreInfo { UserID = 2, TotalScore = 1000 }, + ], placement_points); + + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + } + + [Test] + public void UserIdTieBreaker() + { + var state = new MatchmakingRoomState(); + + // 1 + 2 + 3 + 4 + 5 + 6 + + state.AdvanceRound(); + state.RecordScores( + [ + new SoloScoreInfo { UserID = 4, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 6, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 2, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 3, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 1, TotalScore = 1000 }, + new SoloScoreInfo { UserID = 5, TotalScore = 1000 }, + ], placement_points); + + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(4, state.Users.GetOrAdd(4).Placement); + Assert.AreEqual(5, state.Users.GetOrAdd(5).Placement); + Assert.AreEqual(6, state.Users.GetOrAdd(6).Placement); + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneMetadataClient.cs b/osu.Game.Tests/Online/TestSceneMetadataClient.cs new file mode 100644 index 0000000000..04e1d91edf --- /dev/null +++ b/osu.Game.Tests/Online/TestSceneMetadataClient.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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Tests.Visual; +using osu.Game.Tests.Visual.Metadata; + +namespace osu.Game.Tests.Online +{ + [TestFixture] + [HeadlessTest] + public partial class TestSceneMetadataClient : OsuTestScene + { + private TestMetadataClient client = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = client = new TestMetadataClient(); + }); + + [Test] + public void TestWatchingMultipleTimesInvokesServerMethodsOnce() + { + int countBegin = 0; + int countEnd = 0; + + IDisposable token1 = null!; + IDisposable token2 = null!; + + AddStep("setup", () => + { + client.OnBeginWatchingUserPresence += () => countBegin++; + client.OnEndWatchingUserPresence += () => countEnd++; + }); + + AddStep("begin watching presence (1)", () => token1 = client.BeginWatchingUserPresence()); + AddAssert("server method invoked once", () => countBegin, () => Is.EqualTo(1)); + + AddStep("begin watching presence (2)", () => token2 = client.BeginWatchingUserPresence()); + AddAssert("server method not invoked a second time", () => countBegin, () => Is.EqualTo(1)); + + AddStep("end watching presence (1)", () => token1.Dispose()); + AddAssert("server method not invoked", () => countEnd, () => Is.EqualTo(0)); + + AddStep("end watching presence (2)", () => token2.Dispose()); + AddAssert("server method invoked once", () => countEnd, () => Is.EqualTo(1)); + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs new file mode 100644 index 0000000000..41ffd9c9a9 --- /dev/null +++ b/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs @@ -0,0 +1,185 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Utils; + +namespace osu.Game.Tests.Online +{ + [HeadlessTest] + public partial class TestSceneMultiplayerBeatmapAvailabilityTracker : MultiplayerTestScene + { + private BeatmapManager beatmapManager = null!; + private BeatmapInfo availableBeatmap = null!; + private BeatmapInfo unavailableBeatmap = null!; + + private MultiplayerBeatmapAvailabilityTracker tracker = null!; + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Realm); + + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + + var importedSet = beatmapManager.GetAllUsableBeatmapSets().First(); + availableBeatmap = importedSet.Beatmaps[0]; + unavailableBeatmap = importedSet.Beatmaps[1]; + + Realm.Write(r => r.Remove(r.Find(unavailableBeatmap.ID)!)); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("setup tracker", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + Func? defaultRequestHandler = api.HandleRequest; + + api.HandleRequest = req => + { + switch (req) + { + case GetBeatmapsRequest beatmapsReq: + var availableApiBeatmap = CreateAPIBeatmap(); + availableApiBeatmap.OnlineID = availableBeatmap.OnlineID; + availableApiBeatmap.OnlineBeatmapSetID = availableBeatmap.BeatmapSet!.OnlineID; + availableApiBeatmap.Checksum = availableBeatmap.MD5Hash; + availableApiBeatmap.BeatmapSet!.OnlineID = availableBeatmap.BeatmapSet!.OnlineID; + + var unavailableApiBeatmap = CreateAPIBeatmap(); + unavailableApiBeatmap.OnlineID = unavailableBeatmap.OnlineID; + unavailableApiBeatmap.OnlineBeatmapSetID = unavailableBeatmap.BeatmapSet!.OnlineID; + unavailableApiBeatmap.Checksum = unavailableBeatmap.MD5Hash; + unavailableApiBeatmap.BeatmapSet!.OnlineID = unavailableBeatmap.BeatmapSet!.OnlineID; + + beatmapsReq.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = new List + { + availableApiBeatmap, + unavailableApiBeatmap + } + }); + return true; + + default: + return defaultRequestHandler?.Invoke(req) ?? false; + } + }; + + Child = tracker = new MultiplayerBeatmapAvailabilityTracker(); + }); + } + + [Test] + public void TestEnterRoomWithNotDownloadedBeatmap() + { + AddStep("join room", () => + { + var room = CreateDefaultRoom(); + room.Playlist = [new PlaylistItem(unavailableBeatmap)]; + JoinRoom(room); + }); + + WaitForJoined(); + + AddUntilStep("beatmap is not available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.NotDownloaded)); + } + + [Test] + public void TestEnterRoomWithLocallyAvailableBeatmap() + { + AddStep("join room", () => + { + var room = CreateDefaultRoom(); + room.Playlist = [new PlaylistItem(availableBeatmap)]; + JoinRoom(room); + }); + + WaitForJoined(); + + AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable)); + } + + [Test] + public void TestAvailabilityUpdatesOnItemEdit() + { + AddStep("join room", () => + { + var room = CreateDefaultRoom(); + room.Playlist = [new PlaylistItem(availableBeatmap)]; + JoinRoom(room); + }); + + WaitForJoined(); + + AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable)); + + AddStep("change item to not downloaded beatmap", () => + { + PlaylistItem newItem = new PlaylistItem(MultiplayerClient.ClientRoom!.CurrentPlaylistItem).With(beatmap: new Optional(unavailableBeatmap)); + MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(newItem)).WaitSafely(); + }); + + AddUntilStep("beatmap is not available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.NotDownloaded)); + + AddStep("change item to downloaded beatmap", () => + { + PlaylistItem newItem = new PlaylistItem(MultiplayerClient.ClientRoom!.CurrentPlaylistItem).With(beatmap: new Optional(availableBeatmap)); + MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(newItem)).WaitSafely(); + }); + + AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable)); + } + + [Test] + public void TestAvailabilityUpdatesOnSettingsChange() + { + AddStep("join room", () => + { + var room = CreateDefaultRoom(); + room.Playlist = [new PlaylistItem(availableBeatmap), new PlaylistItem(unavailableBeatmap)]; + JoinRoom(room); + }); + + WaitForJoined(); + + AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable)); + + AddStep("change settings to not downloaded beatmap", () => MultiplayerClient.ChangeServerRoomSettings(new MultiplayerRoomSettings(MultiplayerClient.ClientAPIRoom!) + { + PlaylistItemId = MultiplayerClient.ServerRoom!.Playlist[1].ID + }).WaitSafely()); + + AddUntilStep("beatmap is not available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.NotDownloaded)); + + AddStep("change settings to downloaded beatmap", () => MultiplayerClient.ChangeServerRoomSettings(new MultiplayerRoomSettings(MultiplayerClient.ClientAPIRoom!) + { + PlaylistItemId = MultiplayerClient.ServerRoom!.Playlist[0].ID + }).WaitSafely()); + + AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable)); + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs similarity index 83% rename from osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs rename to osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs index ae3451c3e0..220c23b5bc 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs @@ -1,18 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; -using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.IO.Stores; @@ -27,31 +23,29 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; -using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Online { [HeadlessTest] - public partial class TestSceneOnlinePlayBeatmapAvailabilityTracker : OsuTestScene + public partial class TestScenePlaylistsBeatmapAvailabilityTracker : OsuTestScene { - private RulesetStore rulesets; - private TestBeatmapManager beatmaps; - private TestBeatmapModelDownloader beatmapDownloader; + private TestBeatmapManager beatmaps = null!; + private TestBeatmapModelDownloader beatmapDownloader = null!; - private string testBeatmapFile; - private BeatmapInfo testBeatmapInfo; - private BeatmapSetInfo testBeatmapSet; + private string testBeatmapFile = null!; + private BeatmapInfo testBeatmapInfo = null!; + private BeatmapSetInfo testBeatmapSet = null!; - private readonly Bindable selectedItem = new Bindable(); - private OnlinePlayBeatmapAvailabilityTracker availabilityTracker; + private OnlinePlayBeatmapAvailabilityTracker availabilityTracker = null!; [BackgroundDependencyLoader] private void load(AudioManager audio, GameHost host) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default)); + Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.CacheAs(beatmapDownloader = new TestBeatmapModelDownloader(beatmaps, API)); } @@ -82,16 +76,11 @@ namespace osu.Game.Tests.Online testBeatmapFile = TestResources.GetQuickTestBeatmapForImport(); testBeatmapInfo = getTestBeatmapInfo(testBeatmapFile); - testBeatmapSet = testBeatmapInfo.BeatmapSet; + testBeatmapSet = testBeatmapInfo.BeatmapSet!; Realm.Write(r => r.RemoveAll()); Realm.Write(r => r.RemoveAll()); - selectedItem.Value = new PlaylistItem(testBeatmapInfo) - { - RulesetID = testBeatmapInfo.Ruleset.OnlineID, - }; - recreateChildren(); }); @@ -108,9 +97,15 @@ namespace osu.Game.Tests.Online Children = new Drawable[] { beatmapLookupCache, - availabilityTracker = new OnlinePlayBeatmapAvailabilityTracker + availabilityTracker = new PlaylistsBeatmapAvailabilityTracker { - SelectedItem = { BindTarget = selectedItem, } + PlaylistItem = + { + Value = new PlaylistItem(testBeatmapInfo) + { + RulesetID = testBeatmapInfo.Ruleset.OnlineID, + }, + } } } }; @@ -125,10 +120,10 @@ namespace osu.Game.Tests.Online AddStep("start downloading", () => beatmapDownloader.Download(testBeatmapSet)); addAvailabilityCheckStep("state downloading 0%", () => BeatmapAvailability.Downloading(0.0f)); - AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.SetProgress(0.4f)); + AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)!).SetProgress(0.4f)); addAvailabilityCheckStep("state downloading 40%", () => BeatmapAvailability.Downloading(0.4f)); - AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.TriggerSuccess(testBeatmapFile)); + AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)!).TriggerSuccess(testBeatmapFile)); addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing); AddStep("allow importing", () => beatmaps.AllowImport.Set()); @@ -203,10 +198,10 @@ namespace osu.Game.Tests.Online { public readonly ManualResetEventSlim AllowImport = new ManualResetEventSlim(); - public Live CurrentImport { get; private set; } + public Live? CurrentImport { get; private set; } - public TestBeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, - GameHost host = null, WorkingBeatmap defaultBeatmap = null) + public TestBeatmapManager(Storage storage, RealmAccess realm, IAPIProvider api, AudioManager audioManager, IResourceStore resources, + GameHost? host = null, WorkingBeatmap? defaultBeatmap = null) : base(storage, realm, api, audioManager, resources, host, defaultBeatmap) { } @@ -226,12 +221,13 @@ namespace osu.Game.Tests.Online this.testBeatmapManager = testBeatmapManager; } - public override Live ImportModel(BeatmapSetInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) + public override Live? ImportModel(BeatmapSetInfo item, ArchiveReader? archive = null, ImportParameters parameters = default, + CancellationToken cancellationToken = default) { if (!testBeatmapManager.AllowImport.Wait(TimeSpan.FromSeconds(10), cancellationToken)) throw new TimeoutException("Timeout waiting for import to be allowed."); - return (testBeatmapManager.CurrentImport = base.ImportModel(item, archive, parameters, cancellationToken)); + return testBeatmapManager.CurrentImport = base.ImportModel(item, archive, parameters, cancellationToken); } } } diff --git a/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs new file mode 100644 index 0000000000..4a80c71c3d --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Bogus; +using MessagePack; +using NUnit.Framework; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; + +namespace osu.Game.Tests.OnlinePlay +{ + [TestFixture] + public class MultiplayerPlaylistItemTest + { + [SetUp] + public void Setup() + { + Randomizer.Seed = new Random(1337); + } + + [Test] + public void TestCloneMultiplayerPlaylistItem() + { + var faker = new Faker() + .StrictMode(true) + .RuleFor(o => o.ID, f => f.Random.Long()) + .RuleFor(o => o.OwnerID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash()) + .RuleFor(o => o.RulesetID, f => f.Random.Int()) + .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.Expired, f => f.Random.Bool()) + .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort()) + .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset()) + .RuleFor(o => o.StarRating, f => f.Random.Double()) + .RuleFor(o => o.Freestyle, f => f.Random.Bool()); + + for (int i = 0; i < 100; i++) + { + MultiplayerPlaylistItem item = faker.Generate(); + Assert.That(MessagePackSerializer.SerializeToJson(item.Clone()), Is.EqualTo(MessagePackSerializer.SerializeToJson(item))); + } + } + + [Test] + public void TestConstructFromAPIModel() + { + var faker = new Faker() + .StrictMode(true) + .RuleFor(o => o.ID, f => f.Random.Long()) + .RuleFor(o => o.OwnerID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash()) + .RuleFor(o => o.RulesetID, f => f.Random.Int()) + .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.Expired, f => f.Random.Bool()) + .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort()) + .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset()) + .RuleFor(o => o.StarRating, f => f.Random.Double()) + .RuleFor(o => o.Freestyle, f => f.Random.Bool()); + + for (int i = 0; i < 100; i++) + { + MultiplayerPlaylistItem initialItem = faker.Generate(); + MultiplayerPlaylistItem copiedItem = new MultiplayerPlaylistItem(new PlaylistItem(initialItem)); + Assert.That(MessagePackSerializer.SerializeToJson(copiedItem), Is.EqualTo(MessagePackSerializer.SerializeToJson(initialItem))); + } + } + } +} diff --git a/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs b/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs new file mode 100644 index 0000000000..d463610034 --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.OnlinePlay +{ + [HeadlessTest] + public partial class TestSceneOnlinePlaySubScreenStack : OnlinePlayTestScene + { + private ScreenStack stack = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = stack = new OnlinePlaySubScreenStack + { + RelativeSizeAxes = Axes.Both + }; + }); + + [Test] + public void TestBindablesDisabledWhenRequested() + { + AddAssert("bindables not disabled", () => Beatmap.Disabled || Ruleset.Disabled || SelectedMods.Disabled, () => Is.False); + + AddStep("push screen that disables bindables", () => stack.Push(new ScreenWithExternalBindableDisablement(true))); + AddAssert("bindables disabled", () => Beatmap.Disabled && Ruleset.Disabled && SelectedMods.Disabled, () => Is.True); + + AddStep("push screen that does not disable bindables", () => stack.Push(new ScreenWithExternalBindableDisablement(false))); + AddAssert("bindables not disabled", () => Beatmap.Disabled || Ruleset.Disabled || SelectedMods.Disabled, () => Is.False); + + AddStep("exit one screen", () => stack.Exit()); + AddAssert("bindables disabled", () => Beatmap.Disabled && Ruleset.Disabled && SelectedMods.Disabled, () => Is.True); + } + + [Test] + public void TestModsResetWhenExitToLounge() + { + AddStep("push lounge", () => stack.Push(new PlaylistsLoungeSubScreen())); + + AddStep("push screen with mod", () => stack.Push(new ScreenWithMod(new OsuModDoubleTime()))); + AddUntilStep("wait for screen to load", () => ((OsuScreen)stack.CurrentScreen).IsLoaded); + AddAssert("mod set", () => SelectedMods.Value.Count, () => Is.GreaterThan(0)); + + AddStep("exit to lounge", () => stack.Exit()); + AddAssert("mods reset", () => SelectedMods.Value.Count, () => Is.Zero); + } + + private partial class ScreenWithExternalBindableDisablement : OsuScreen + { + public override bool DisallowExternalBeatmapRulesetChanges { get; } + + public ScreenWithExternalBindableDisablement(bool disableBindables) + { + DisallowExternalBeatmapRulesetChanges = disableBindables; + } + } + + private partial class ScreenWithMod : OsuScreen + { + private readonly Mod mod; + + public ScreenWithMod(Mod mod) + { + this.mod = mod; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Mods.Value = [mod]; + } + } + } +} diff --git a/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz b/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz new file mode 100644 index 0000000000..40f33dea75 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk new file mode 100644 index 0000000000..23322e7373 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk new file mode 100644 index 0000000000..74abef25ca Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250308.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250308.osk new file mode 100644 index 0000000000..cbaacd3f4b Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250308.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250424.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250424.osk new file mode 100644 index 0000000000..24aa90cdd0 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250424.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250809.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250809.osk new file mode 100644 index 0000000000..bea78dcbef Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250809.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk b/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk new file mode 100644 index 0000000000..8ed25fa8f4 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk differ diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index e0572e604c..469bc8ee73 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -29,6 +29,11 @@ namespace osu.Game.Tests.Resources { public const double QUICK_BEATMAP_LENGTH = 10000; + public const string COVER_IMAGE_1 = "https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg"; + public const string COVER_IMAGE_2 = "https://assets.ppy.sh/user-cover-presets/7/4a0ccb7b7fdd5c4238b11f0e7c686760fe2c99c6472b19400e82d1a8ff503e31.jpeg"; + public const string COVER_IMAGE_3 = "https://assets.ppy.sh/user-cover-presets/12/6e8d3402c8080c2d9549a98321e1bff111dd9c94603ccdb237597479cab6e8a7.jpeg"; + public const string COVER_IMAGE_4 = "https://assets.ppy.sh/user-cover-presets/17/80f82e4c2b27d8d6eed3ce89708ec27343e5ac63389cba6b5fb4550776562d08.jpeg"; + private static readonly TemporaryNativeStorage temp_storage = new TemporaryNativeStorage("TestResources"); public static DllResourceStore GetStore() => new DllResourceStore(typeof(TestResources).Assembly); @@ -99,7 +104,7 @@ namespace osu.Game.Tests.Resources { // Create random metadata, then we can check if sorting works based on these Artist = "Some Artist " + RNG.Next(0, 9), - Title = $"Some Song (set id {setId:000}) {Guid.NewGuid()}", + Title = $"Some Song (set id {setId:000000}) {Guid.NewGuid()}", Author = { Username = "Some Guy " + RNG.Next(0, 9) }, }; @@ -178,7 +183,7 @@ namespace osu.Game.Tests.Resources { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = COVER_IMAGE_3, }, BeatmapInfo = beatmap, BeatmapHash = beatmap.Hash, diff --git a/osu.Game.Tests/Resources/noop-fade-transform-is-ignored-for-lifetime.osb b/osu.Game.Tests/Resources/noop-fade-transform-is-ignored-for-lifetime.osb new file mode 100644 index 0000000000..aca9bf926a --- /dev/null +++ b/osu.Game.Tests/Resources/noop-fade-transform-is-ignored-for-lifetime.osb @@ -0,0 +1,8 @@ +[Events] +//Storyboard Layer 0 (Background) +Sprite,Background,TopCentre,"img.jpg",320,240 + F,0,1000,1000,0,0 // should be ignored + F,0,1500,1600,0,1 +Sprite,Background,TopCentre,"img.jpg",320,240 + F,0,1000,1000,0,0 // should be ignored + F,0,1500,1600,1,1 diff --git a/osu.Game.Tests/Resources/old-skin/score-0.png b/osu.Game.Tests/Resources/old-skin/score-0.png deleted file mode 100644 index 8304617d8c..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-0.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-1.png b/osu.Game.Tests/Resources/old-skin/score-1.png deleted file mode 100644 index c3b85eb873..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-1.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-2.png b/osu.Game.Tests/Resources/old-skin/score-2.png deleted file mode 100644 index 7f65eb7ca7..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-2.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-3.png b/osu.Game.Tests/Resources/old-skin/score-3.png deleted file mode 100644 index 82bec3babe..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-3.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-4.png b/osu.Game.Tests/Resources/old-skin/score-4.png deleted file mode 100644 index 5e38c75a9d..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-4.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-5.png b/osu.Game.Tests/Resources/old-skin/score-5.png deleted file mode 100644 index a562d9f2ac..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-5.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-6.png b/osu.Game.Tests/Resources/old-skin/score-6.png deleted file mode 100644 index b4cf81f26e..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-6.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-7.png b/osu.Game.Tests/Resources/old-skin/score-7.png deleted file mode 100644 index a23f5379b2..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-7.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-8.png b/osu.Game.Tests/Resources/old-skin/score-8.png deleted file mode 100644 index 430b18509d..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-8.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-9.png b/osu.Game.Tests/Resources/old-skin/score-9.png deleted file mode 100644 index add1202c31..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-9.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-comma.png b/osu.Game.Tests/Resources/old-skin/score-comma.png deleted file mode 100644 index f68d32957f..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-comma.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-dot.png b/osu.Game.Tests/Resources/old-skin/score-dot.png deleted file mode 100644 index 80c39b8745..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-dot.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-percent.png b/osu.Game.Tests/Resources/old-skin/score-percent.png deleted file mode 100644 index fc750abc7e..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-percent.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/score-x.png b/osu.Game.Tests/Resources/old-skin/score-x.png deleted file mode 100644 index 779773f8bd..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/score-x.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-bg.png b/osu.Game.Tests/Resources/old-skin/scorebar-bg.png deleted file mode 100644 index 1e94f464ca..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-bg.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png deleted file mode 100644 index 1119ce289e..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png deleted file mode 100644 index 7669474d8b..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png deleted file mode 100644 index 70fdb4b146..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png deleted file mode 100644 index 18ac6976c9..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-ki.png b/osu.Game.Tests/Resources/old-skin/scorebar-ki.png deleted file mode 100644 index a030c5801e..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-ki.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png deleted file mode 100644 index ac5a2c5893..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png deleted file mode 100644 index 507be0463f..0000000000 Binary files a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png and /dev/null differ diff --git a/osu.Game.Tests/Resources/old-skin/skin.ini b/osu.Game.Tests/Resources/old-skin/skin.ini deleted file mode 100644 index 94c6b5b58d..0000000000 --- a/osu.Game.Tests/Resources/old-skin/skin.ini +++ /dev/null @@ -1,2 +0,0 @@ -[General] -// no version specified means v1 diff --git a/osu.Game.Tests/Resources/too-many-combo-colours.osu b/osu.Game.Tests/Resources/too-many-combo-colours.osu new file mode 100644 index 0000000000..477e362a6d --- /dev/null +++ b/osu.Game.Tests/Resources/too-many-combo-colours.osu @@ -0,0 +1,73 @@ +osu file format v14 + +[General] +AudioFilename: 03. Renatus - Soleily 192kbps.mp3 +AudioLeadIn: 0 +PreviewTime: 164471 +Countdown: 0 +SampleSet: Soft +StackLeniency: 0.7 +Mode: 0 +LetterboxInBreaks: 0 +WidescreenStoryboard: 0 + +[Editor] +Bookmarks: 11505,22054,32604,43153,53703,64252,74802,85351,95901,106450,116999,119637,130186,140735,151285,161834,164471,175020,185570,196119,206669,209306 +DistanceSpacing: 1.8 +BeatDivisor: 4 +GridSize: 4 +TimelineZoom: 2 + +[Metadata] +Title:Renatus +TitleUnicode:Renatus +Artist:Soleily +ArtistUnicode:Soleily +Creator:Gamu +Version:Insane +Source: +Tags:MBC7 Unisphere 地球ヤバイEP Chikyu Yabai +BeatmapID:557821 +BeatmapSetID:241526 + +[Difficulty] +HPDrainRate:6.5 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9 +SliderMultiplier:1.8 +SliderTickRate:2 + +[Events] +//Background and Video events +0,0,"machinetop_background.jpg",0,0 +//Break Periods +2,122474,140135 +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples + +[TimingPoints] +956,329.67032967033,4,2,0,60,1,0 + + +[Colours] +Combo1:142,199,255 +Combo2:255,128,128 +Combo3:128,255,255 +Combo4:128,255,128 +Combo5:255,187,255 +Combo6:255,177,140 +Combo7:100,100,100 +Combo8:142,199,255 +Combo9:255,128,128 +Combo10:128,255,255 +Combo11:128,255,128 +Combo12:255,187,255 +Combo13:255,177,140 +Combo14:100,100,100 + +[HitObjects] +192,168,956,6,0,P|184:128|200:80,1,90,4|0,1:2|0:0,0:0:0:0: diff --git a/osu.Game.Tests/Resources/video-custom-alpha-transform.osb b/osu.Game.Tests/Resources/video-custom-alpha-transform.osb new file mode 100644 index 0000000000..39fcf87c06 --- /dev/null +++ b/osu.Game.Tests/Resources/video-custom-alpha-transform.osb @@ -0,0 +1,5 @@ +osu file format v14 + +[Events] +Video,-5678,"Video.avi",0,0 + F,0,1500,1600,0,1 diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 1647fbee42..f45422e0c4 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -421,6 +421,65 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.That(scoreProcessor.Rank.Value, Is.EqualTo(ScoreRank.SH)); } + [Test] + public void TestComboAccounting([Values] bool shuffleResults) + { + var testBeatmap = new Beatmap + { + HitObjects = Enumerable.Range(1, 40).Select(i => new TestHitObject(HitResult.Perfect, HitResult.Miss)).ToList(), + }; + scoreProcessor.ApplyBeatmap(testBeatmap); + + var results = new List(); + JudgementResult judgementResult; + + for (int i = 0; i < 25; ++i) + { + judgementResult = new JudgementResult(testBeatmap.HitObjects[i], new TestJudgement(HitResult.Perfect, HitResult.Miss)) + { + Type = HitResult.Perfect + }; + results.Add(judgementResult); + scoreProcessor.ApplyResult(judgementResult); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(i + 1)); + } + + judgementResult = new JudgementResult(testBeatmap.HitObjects[25], new TestJudgement(HitResult.Perfect, HitResult.Miss)) + { + Type = HitResult.Miss + }; + results.Add(judgementResult); + scoreProcessor.ApplyResult(judgementResult); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); + + for (int i = 26; i < 40; ++i) + { + judgementResult = new JudgementResult(testBeatmap.HitObjects[i], new TestJudgement(HitResult.Perfect, HitResult.Miss)) + { + Type = HitResult.Perfect + }; + results.Add(judgementResult); + scoreProcessor.ApplyResult(judgementResult); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(i - 25)); + } + + Assert.That(scoreProcessor.MaximumStatistics[HitResult.Perfect], Is.EqualTo(40)); + Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(14)); + Assert.That(scoreProcessor.HighestCombo.Value, Is.EqualTo(25)); + + // random shuffle is VERY extreme and overkill. + // it might not work correctly for any other `ScoreProcessor` property, and the intermediate results likely make no sense. + // the goal is only to demonstrate idempotency to zero when reverting all results. + var random = new Random(20250519); + var toRevert = shuffleResults ? results.OrderBy(_ => random.Next()).ToList() : Enumerable.Reverse(results); + + foreach (var result in toRevert) + scoreProcessor.RevertResult(result); + + Assert.That(scoreProcessor.Combo.Value, Is.Zero); + Assert.That(scoreProcessor.HighestCombo.Value, Is.Zero); + } + private class TestJudgement : Judgement { public override HitResult MaxResult { get; } diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index 6639b6dd68..f9dfb86cb8 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -149,6 +149,8 @@ namespace osu.Game.Tests.Rulesets public IBindable AggregateFrequency => throw new NotImplementedException(); public IBindable AggregateTempo => throw new NotImplementedException(); + public void Invalidate(string name) => throw new NotImplementedException(); + public int PlaybackConcurrency { get; set; } public void AddExtension(string extension) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index 9c72804a6b..6558834a63 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -215,6 +215,35 @@ namespace osu.Game.Tests.Scores.IO } } + [Test] + public void TestScoreWithInvalidModCombinationsWillNotImport() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + var beatmap = BeatmapImportHelper.LoadOszIntoOsu(osu, TestResources.GetQuickTestBeatmapForImport()).GetResultSafely(); + + var toImport = new ScoreInfo + { + User = new APIUser { Username = "Test user" }, + BeatmapInfo = beatmap.Beatmaps.First(), + Ruleset = new OsuRuleset().RulesetInfo, + ClientVersion = "12345", + Mods = new Mod[] { new OsuModHalfTime(), new OsuModDoubleTime() }, + }; + + Assert.Throws(() => LoadScoreIntoOsu(osu, toImport)); + } + finally + { + host.Exit(); + } + } + } + [Test] public void TestImportStatistics() { diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index 62e7a80435..2535d5b2e2 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -294,6 +294,92 @@ namespace osu.Game.Tests.Skins.IO #endregion + [Test] + public async Task TestExternallyMountingWithSubDirectory() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + var zipStream = new MemoryStream(); + using var zip = ZipArchive.Create(); + zip.AddEntry("folder/test.png", new MemoryStream(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF })); + zip.SaveTo(zipStream); + + var import = await loadSkinIntoOsu(osu, new ImportTask(zipStream, "test skin.osk")); + + var skinManager = osu.Dependencies.Get(); + var externalEdit = await skinManager.BeginExternalEditing(import.PerformRead(s => s.Detach())); // should not fail + + Assert.That(Directory.Exists(externalEdit.MountedPath)); + + var directoryInfo = new DirectoryInfo(externalEdit.MountedPath); + + Assert.That(directoryInfo.GetFiles().Select(f => f.Name), Is.EquivalentTo(new[] + { + "skin.ini", + })); + + var subDirectory = directoryInfo.GetDirectories().Single(); + Assert.That(subDirectory.Name, Is.EqualTo("folder")); + Assert.That(subDirectory.GetFiles().Select(f => f.Name), Is.EquivalentTo(new[] + { + "test.png", + })); + + Task finishTask = Task.CompletedTask; + host.UpdateThread.Scheduler.Add(() => finishTask = externalEdit.Finish()); + await finishTask; + } + finally + { + host.Exit(); + } + } + } + + /// + /// Note that this test passing / failing is platform / OS-specific (if it is to fail, it'll fail on windows). + /// + [Test] + public async Task TestExternallyMountingImportWithInvalidFilename() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + var zipStream = new MemoryStream(); + using var zip = ZipArchive.Create(); + zip.AddEntry("test?.png", new MemoryStream(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF })); + zip.SaveTo(zipStream); + + var import = await loadSkinIntoOsu(osu, new ImportTask(zipStream, "test skin.osk")); + + var skinManager = osu.Dependencies.Get(); + var externalEdit = await skinManager.BeginExternalEditing(import.PerformRead(s => s.Detach())); // should not fail + + Assert.That(Directory.Exists(externalEdit.MountedPath)); + Assert.That(new DirectoryInfo(externalEdit.MountedPath).GetFiles().Select(f => f.Name), Is.EquivalentTo(new[] + { + "skin.ini", + "test.png" + })); + + Task finishTask = Task.CompletedTask; + host.UpdateThread.Scheduler.Add(() => finishTask = externalEdit.Finish()); + await finishTask; + } + finally + { + host.Exit(); + } + } + } + private void assertCorrectMetadata(Live import1, string name, string creator, decimal version, OsuGameBase osu) { import1.PerformRead(i => diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index 7372557161..a493f334fd 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -13,10 +13,10 @@ using osu.Game.Audio; using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Screens.Menu; -using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osu.Game.Skinning.Components; +using osu.Game.Skinning.Triangles; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Skins @@ -68,7 +68,19 @@ namespace osu.Game.Tests.Skins // Covers legacy rank display "Archives/modified-classic-20230809.osk", // Covers legacy key counter - "Archives/modified-classic-20240724.osk" + "Archives/modified-classic-20240724.osk", + // Covers skinnable mod display + "Archives/modified-default-20241207.osk", + // Covers skinnable spectator list + "Archives/modified-argon-20250116.osk", + // Covers player team flag + "Archives/modified-argon-20250214.osk", + // Covers skinnable leaderboard + "Archives/modified-argon-20250424.osk", + // Covers "Argon" unstable rate counter + "Archives/modified-argon-20250809.osk", + // Covers "Argon" judgement counter + "Archives/modified-argon-20250308.osk", }; /// @@ -162,7 +174,7 @@ namespace osu.Game.Tests.Skins { var skin = new TestSkin(new SkinInfo(), null, storage); Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8)); - Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(TrianglesUnstableRateCounter))); Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); } diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs index 54a722cee0..7b22ff1d6a 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -131,21 +131,6 @@ namespace osu.Game.Tests.Visual.Background assertNoBackgrounds(); } - [Test] - public void TestDelayedConnectivity() - { - registerBackgroundsResponse(DateTimeOffset.Now.AddDays(30)); - setSeasonalBackgroundMode(SeasonalBackgroundMode.Always); - AddStep("go offline", () => dummyAPI.SetState(APIState.Offline)); - - createLoader(); - assertNoBackgrounds(); - - AddStep("go online", () => dummyAPI.SetState(APIState.Online)); - - assertAnyBackground(); - } - private void registerBackgroundsResponse(DateTimeOffset endDate) => AddStep("setup request handler", () => { @@ -185,7 +170,8 @@ namespace osu.Game.Tests.Visual.Background { previousBackground = (SeasonalBackground)backgroundContainer.SingleOrDefault(); background = backgroundLoader.LoadNextBackground(); - LoadComponentAsync(background, bg => backgroundContainer.Child = bg); + if (background != null) + LoadComponentAsync(background, bg => backgroundContainer.Child = bg); }); AddUntilStep("background loaded", () => background.IsLoaded); diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index d8be57382f..3021589cdb 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Linq; using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,6 +16,7 @@ using osu.Framework.Input.Events; using osu.Framework.Input.States; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -30,7 +32,8 @@ using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; +using osu.Game.Storyboards.Drawables; using osu.Game.Tests.Resources; using osuTK; using osuTK.Graphics; @@ -45,21 +48,22 @@ namespace osu.Game.Tests.Visual.Background private LoadBlockingTestPlayer player; private BeatmapManager manager; private RulesetStore rulesets; + private UpdateCounter storyboardUpdateCounter; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); manager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - Add(detachedBeatmapStore); + Add(beatmapStore); Beatmap.SetDefault(); } @@ -194,6 +198,28 @@ namespace osu.Game.Tests.Visual.Background AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); } + [Test] + public void TestStoryboardUpdatesWhenDimmed() + { + performFullSetup(); + createFakeStoryboard(); + + AddStep("Enable fully dimmed storyboard", () => + { + player.StoryboardReplacesBackground.Value = true; + player.StoryboardEnabled.Value = true; + player.DimmableStoryboard.IgnoreUserSettings.Value = false; + songSelect.DimLevel.Value = 1f; + }); + + AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible); + + AddWaitStep("wait some", 20); + + AddUntilStep("Storyboard is always present", () => player.ChildrenOfType().Single().AlwaysPresent, () => Is.True); + AddUntilStep("Dimmable storyboard content is being updated", () => storyboardUpdateCounter.StoryboardContentLastUpdated, () => Is.EqualTo(Time.Current).Within(100)); + } + [Test] public void TestStoryboardIgnoreUserSettings() { @@ -269,15 +295,19 @@ namespace osu.Game.Tests.Visual.Background { player.StoryboardEnabled.Value = false; player.StoryboardReplacesBackground.Value = false; - player.DimmableStoryboard.Add(new OsuSpriteText + player.DimmableStoryboard.AddRange(new Drawable[] { - Size = new Vector2(500, 50), - Alpha = 1, - Colour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "THIS IS A STORYBOARD", - Font = new FontUsage(size: 50) + storyboardUpdateCounter = new UpdateCounter(), + new OsuSpriteText + { + Size = new Vector2(500, 50), + Alpha = 1, + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "THIS IS A STORYBOARD", + Font = new FontUsage(size: 50) + } }); }); @@ -295,7 +325,7 @@ namespace osu.Game.Tests.Visual.Background private void setupUserSettings() { AddUntilStep("Song select is current", () => songSelect.IsCurrentScreen()); - AddUntilStep("Song select has selection", () => songSelect.Carousel?.SelectedBeatmapInfo != null); + AddUntilStep("Song select has selection", () => songSelect.Carousel?.CurrentGroupedBeatmap != null); AddStep("Set default user settings", () => { SelectedMods.Value = new[] { new OsuModNoFail() }; @@ -310,7 +340,7 @@ namespace osu.Game.Tests.Visual.Background rulesets?.Dispose(); } - private partial class DummySongSelect : PlaySongSelect + private partial class DummySongSelect : SoloSongSelect { private FadeAccessibleBackground background; @@ -325,7 +355,7 @@ namespace osu.Game.Tests.Visual.Background public readonly Bindable DimLevel = new BindableDouble(); public readonly Bindable BlurLevel = new BindableDouble(); - public new BeatmapCarousel Carousel => base.Carousel; + public BeatmapCarousel Carousel => this.ChildrenOfType().SingleOrDefault(); [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -338,7 +368,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundDimmed() => background.CurrentColour == OsuColour.Gray(1f - background.CurrentDim); - public bool IsBackgroundUndimmed() => background.CurrentColour == Color4.White; + public bool IsBackgroundUndimmed() => background.CurrentColour == new Color4(0.9f, 0.9f, 0.9f, 1f); public bool IsUserBlurApplied() => Precision.AlmostEquals(background.CurrentBlur, new Vector2((float)BlurLevel.Value * BackgroundScreenBeatmap.USER_BLUR_FACTOR), 0.1f); @@ -346,14 +376,14 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundVisible() => background.CurrentAlpha == 1; - public bool IsBackgroundBlur() => Precision.AlmostEquals(background.CurrentBlur, new Vector2(BACKGROUND_BLUR), 0.1f); + public bool IsBackgroundBlur() => Precision.AlmostBigger(background.CurrentBlur.X, 0, 0.1f); public bool CheckBackgroundBlur(Vector2 expected) => Precision.AlmostEquals(background.CurrentBlur, expected, 0.1f); /// /// Make sure every time a screen gets pushed, the background doesn't get replaced /// - /// Whether or not the original background (The one created in DummySongSelect) is still the current background + /// Whether the original background (The one created in DummySongSelect) is still the current background public bool IsBackgroundCurrent() => background?.IsCurrentScreen() == true; } @@ -384,7 +414,7 @@ namespace osu.Game.Tests.Visual.Background public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard; - // Whether or not the player should be allowed to load. + // Whether the player should be allowed to load. public bool BlockLoad; public Bindable StoryboardEnabled; @@ -451,6 +481,17 @@ namespace osu.Game.Tests.Visual.Background } } + private partial class UpdateCounter : Drawable + { + public double StoryboardContentLastUpdated; + + protected override void Update() + { + base.Update(); + StoryboardContentLastUpdated = Time.Current; + } + } + private partial class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground { public Color4 CurrentColour => Content.Colour; diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs index fed26d8acb..2f31911fac 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs @@ -16,6 +16,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Beatmaps.Drawables.Cards.Buttons; +using osu.Game.Beatmaps.Drawables.Cards.Statistics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -63,7 +64,11 @@ namespace osu.Game.Tests.Visual.Beatmaps withStatistics.NominationStatus = new BeatmapSetNominationStatus { Current = 1, - Required = 2 + RequiredMeta = + { + MainRuleset = 2, + NonMainRuleset = 1, + } }; var undownloadable = getUndownloadableBeatmapSet(); @@ -78,7 +83,11 @@ namespace osu.Game.Tests.Visual.Beatmaps someDifficulties.NominationStatus = new BeatmapSetNominationStatus { Current = 2, - Required = 2 + RequiredMeta = + { + MainRuleset = 2, + NonMainRuleset = 1, + } }; var manyDifficulties = getManyDifficultiesBeatmapSet(100); @@ -220,6 +229,9 @@ namespace osu.Game.Tests.Visual.Beatmaps } private Drawable createContent(OverlayColourScheme colourScheme, Func creationFunc) + => createContent(colourScheme, testCases.Select(creationFunc).ToArray()); + + private Drawable createContent(OverlayColourScheme colourScheme, Drawable[] cards) { var colourProvider = new OverlayColourProvider(colourScheme); @@ -247,7 +259,7 @@ namespace osu.Game.Tests.Visual.Beatmaps Direction = FillDirection.Full, Padding = new MarginPadding(10), Spacing = new Vector2(10), - ChildrenEnumerable = testCases.Select(creationFunc) + ChildrenEnumerable = cards } } } @@ -320,5 +332,54 @@ namespace osu.Game.Tests.Visual.Beatmaps BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); } + + [Test] + public void TestNominations() + { + AddStep("create cards", () => + { + var singleRuleset = CreateAPIBeatmapSet(Ruleset.Value); + singleRuleset.HypeStatus = new BeatmapSetHypeStatus(); + singleRuleset.NominationStatus = new BeatmapSetNominationStatus + { + Current = 4, + RequiredMeta = + { + MainRuleset = 5, + NonMainRuleset = 1, + } + }; + + var multipleRulesets = getManyDifficultiesBeatmapSet(3); + multipleRulesets.HypeStatus = new BeatmapSetHypeStatus(); + multipleRulesets.NominationStatus = new BeatmapSetNominationStatus + { + Current = 4, + RequiredMeta = + { + MainRuleset = 5, + NonMainRuleset = 1, + } + }; + + Child = createContent(OverlayColourScheme.Blue, new Drawable[] + { + new BeatmapCardNormal(singleRuleset), + new BeatmapCardNormal(multipleRulesets), + }); + }); + + // first card: only has main ruleset, required nominations = main_ruleset = 5 + AddAssert("first card has single ruleset", () => firstCard().BeatmapSet.Beatmaps.GroupBy(b => b.Ruleset).Count(), () => Is.EqualTo(1)); + AddAssert("first card nominations = 4/5", () => firstCard().ChildrenOfType().Single().TooltipText.ToString(), () => Is.EqualTo("Nominations: 4/5")); + + // second card: has non-main rulesets, required nominations = main_ruleset + non_main_ruleset * (count of non-main rulesets) = 5 + 1 * 2 = 7 + AddAssert("second card has three rulesets", () => secondCard().BeatmapSet.Beatmaps.GroupBy(b => b.Ruleset).Count(), () => Is.EqualTo(3)); + AddAssert("second card nominations = 4/7", () => secondCard().ChildrenOfType().Single().TooltipText.ToString(), () => Is.EqualTo("Nominations: 4/7")); + + // order is reversed due to the cards being inside a reverse child-id fill flow. + BeatmapCardNormal firstCard() => this.ChildrenOfType().ElementAt(1); + BeatmapCardNormal secondCard() => this.ChildrenOfType().ElementAt(0); + } } } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs index c33033624a..81abe105f1 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs @@ -91,6 +91,6 @@ namespace osu.Game.Tests.Visual.Beatmaps } private void assertCorrectIcon(bool favourited) => AddAssert("icon correct", - () => this.ChildrenOfType().Single().Icon.Equals(favourited ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart)); + () => this.ChildrenOfType().First().Icon.Equals(favourited ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart)); } } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs index dcc4654437..1651adc08f 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs @@ -19,6 +19,9 @@ namespace osu.Game.Tests.Visual.Beatmaps { public partial class TestSceneBeatmapSetOnlineStatusPill : ThemeComparisonTestScene { + private bool showUnknownStatus; + private bool animated = true; + protected override Drawable CreateContent() => new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -26,12 +29,21 @@ namespace osu.Game.Tests.Visual.Beatmaps Origin = Anchor.Centre, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 10), - ChildrenEnumerable = Enum.GetValues(typeof(BeatmapOnlineStatus)).Cast().Select(status => new BeatmapSetOnlineStatusPill + ChildrenEnumerable = Enum.GetValues(typeof(BeatmapOnlineStatus)).Cast().Select(status => new Container { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Status = status + RelativeSizeAxes = Axes.X, + Height = 20, + Children = new Drawable[] + { + new BeatmapSetOnlineStatusPill + { + ShowUnknownStatus = showUnknownStatus, + Animated = animated, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Status = status + }, + } }) }; @@ -48,6 +60,18 @@ namespace osu.Game.Tests.Visual.Beatmaps pill.Width = 90; })); + AddStep("toggle show unknown", () => + { + showUnknownStatus = !showUnknownStatus; + CreateThemedContent(OverlayColourScheme.Red); + }); + + AddStep("toggle animate", () => + { + animated = !animated; + CreateThemedContent(OverlayColourScheme.Red); + }); + AddStep("unset fixed width", () => statusPills.ForEach(pill => pill.AutoSizeAxes = Axes.Both)); } @@ -65,11 +89,6 @@ namespace osu.Game.Tests.Visual.Beatmaps pill.Status = BeatmapOnlineStatus.LocallyModified; break; - // skip none - case BeatmapOnlineStatus.LocallyModified: - pill.Status = BeatmapOnlineStatus.Graveyard; - break; - default: pill.Status = (pill.Status + 1); break; diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs index 11fa6ed92d..39de2b7bc9 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs @@ -1,12 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Game.Beatmaps; +using osu.Framework.Testing; using osu.Game.Beatmaps.Drawables; using osu.Game.Online.API.Requests.Responses; using osuTK; @@ -15,16 +13,18 @@ namespace osu.Game.Tests.Visual.Beatmaps { public partial class TestSceneDifficultySpectrumDisplay : OsuTestScene { - private DifficultySpectrumDisplay display; + private DifficultySpectrumDisplay display = null!; - private static APIBeatmapSet createBeatmapSetWith(params (int rulesetId, double stars)[] difficulties) => new APIBeatmapSet + [SetUpSteps] + public void SetUpSteps() { - Beatmaps = difficulties.Select(difficulty => new APIBeatmap + AddStep("create spectrum display", () => Child = display = new DifficultySpectrumDisplay { - RulesetID = difficulty.rulesetId, - StarRating = difficulty.stars - }).ToArray() - }; + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(3) + }); + } [Test] public void TestSingleRuleset() @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.Beatmaps (rulesetId: 0, stars: 3.2), (rulesetId: 0, stars: 5.6)); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] @@ -47,7 +47,7 @@ namespace osu.Game.Tests.Visual.Beatmaps (rulesetId: 1, stars: 4.3), (rulesetId: 0, stars: 5.6)); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] @@ -61,52 +61,30 @@ namespace osu.Game.Tests.Visual.Beatmaps (rulesetId: 0, stars: 5.6), (rulesetId: 15, stars: 7.8)); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] public void TestMaximumUncollapsed() { var beatmapSet = createBeatmapSetWith(Enumerable.Range(0, 12).Select(i => (rulesetId: i % 4, stars: 2.5 + i * 0.25)).ToArray()); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } [Test] public void TestMinimumCollapsed() { var beatmapSet = createBeatmapSetWith(Enumerable.Range(0, 13).Select(i => (rulesetId: i % 4, stars: 2.5 + i * 0.25)).ToArray()); - createDisplay(beatmapSet); + AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet); } - [Test] - public void TestAdjustableDotSize() + private static APIBeatmapSet createBeatmapSetWith(params (int rulesetId, double stars)[] difficulties) => new APIBeatmapSet { - var beatmapSet = createBeatmapSetWith( - (rulesetId: 0, stars: 2.0), - (rulesetId: 3, stars: 2.3), - (rulesetId: 0, stars: 3.2), - (rulesetId: 1, stars: 4.3), - (rulesetId: 0, stars: 5.6)); - - createDisplay(beatmapSet); - - AddStep("change dot dimensions", () => + Beatmaps = difficulties.Select(difficulty => new APIBeatmap { - display.DotSize = new Vector2(8, 12); - display.DotSpacing = 2; - }); - AddStep("change dot dimensions back", () => - { - display.DotSize = new Vector2(4, 8); - display.DotSpacing = 1; - }); - } - - private void createDisplay(IBeatmapSetInfo beatmapSetInfo) => AddStep("create spectrum display", () => Child = display = new DifficultySpectrumDisplay(beatmapSetInfo) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(3) - }); + RulesetID = difficulty.rulesetId, + StarRating = difficulty.stars + }).ToArray() + }; } } diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs new file mode 100644 index 0000000000..dd44c92c09 --- /dev/null +++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Users; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Components +{ + public partial class TestSceneFriendPresenceNotifier : OsuManualInputManagerTestScene + { + private ChannelManager channelManager = null!; + private NotificationOverlay notificationOverlay = null!; + private ChatOverlay chatOverlay = null!; + private TestMetadataClient metadataClient = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(ChannelManager), channelManager = new ChannelManager(API)), + (typeof(INotificationOverlay), notificationOverlay = new NotificationOverlay()), + (typeof(ChatOverlay), chatOverlay = new ChatOverlay()), + (typeof(MetadataClient), metadataClient = new TestMetadataClient()), + ], + Children = new Drawable[] + { + channelManager, + notificationOverlay, + chatOverlay, + metadataClient, + new FriendPresenceNotifier() + } + }; + + for (int i = 1; i <= 100; i++) + ((DummyAPIAccess)API).LocalUserState.Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } }); + }); + + [Test] + public void TestNotifications() + { + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + AddStep("bring friend 1 offline", () => metadataClient.FriendPresenceUpdated(1, null)); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + + [Test] + public void TestSingleUserNotificationOpensChat() + { + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("user channel selected", + () => channelManager.CurrentChannel.Value.Name, + () => Is.EqualTo(((DummyAPIAccess)API).LocalUserState.Friends[0].TargetUser!.Username)); + } + + [Test] + public void TestMultipleUserNotificationDoesNotOpenChat() + { + AddStep("bring friends 1 & 2 online", () => + { + metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("chat overlay not opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + } + + [Test] + public void TestNonFriendsDoNotNotify() + { + AddStep("bring non-friend 1000 online", () => metadataClient.UserPresenceUpdated(1000, new UserPresence { Status = UserStatus.Online })); + AddWaitStep("wait for possible notification", 10); + AddAssert("no notification", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); + } + + [Test] + public void TestPostManyDebounced() + { + AddStep("bring friends 1-10 online", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("bring friends 1-10 offline", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.FriendPresenceUpdated(i, null); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + } +} diff --git a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs index b334616125..3cce378247 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs @@ -220,10 +220,13 @@ namespace osu.Game.Tests.Visual.Components protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); if (registerAsOwner) - dependencies.CacheAs(this); - return dependencies; + { + // Automatically handled by interface caching. + return base.CreateChildDependencies(parent); + } + + return new DependencyContainer(); } } diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs index 0742ed5eb9..f1422b4654 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs @@ -6,6 +6,8 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Metadata; @@ -13,9 +15,11 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.Metadata; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.DailyChallenge { @@ -39,7 +43,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge { var room = new Room { - RoomID = 1234, Name = "Daily Challenge: June 4, 2024", Playlist = [ @@ -57,12 +60,43 @@ namespace osu.Game.Tests.Visual.DailyChallenge AddStep("push screen", () => LoadScreen(new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); } + [Test] + public void TestUseTheseModsUnavailableIfNoFreeMods() + { + var room = new Room + { + Name = "Daily Challenge: June 4, 2024", + Playlist = + [ + new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) + { + RequiredMods = [new APIMod(new OsuModTraceable())], + AllowedMods = [] + } + ], + EndDate = DateTimeOffset.Now.AddHours(12), + Category = RoomCategory.DailyChallenge + }; + + AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); + Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; + AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); + AddUntilStep("wait for pushed", () => screen.IsCurrentScreen()); + AddStep("force transforms to finish", () => FinishTransforms(true)); + AddStep("right click second score", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Right); + }); + AddAssert("use these mods not present", + () => this.ChildrenOfType().All(m => m.Items.All(item => item.Text.Value != "Use these mods"))); + } + [Test] public void TestNotifications() { var room = new Room { - RoomID = 1234, Name = "Daily Challenge: June 4, 2024", Playlist = [ @@ -77,7 +111,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge }; AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); - AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 }); + AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = room.RoomID!.Value }); Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); @@ -91,7 +125,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge { var room = new Room { - RoomID = 1234, Name = "Daily Challenge: June 4, 2024", Playlist = [ @@ -106,7 +139,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge }; AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); - AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 }); + AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = room.RoomID!.Value }); Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs index b9470f3be4..becce7b22a 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs @@ -16,6 +16,7 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.DailyChallenge { @@ -129,7 +130,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); feed.AddNewScore(ev); @@ -141,7 +142,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(1, 1000)); feed.AddNewScore(ev); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs index 4b784f661d..eda596effb 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); feed.AddNewScore(ev); @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(11, 1000)); var testScore = TestResources.CreateTestScoreInfo(); @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(1, 10)); feed.AddNewScore(ev); @@ -108,7 +108,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); feed.AddNewScore(ev); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs index d6665e24a4..97b957df43 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs @@ -44,17 +44,17 @@ namespace osu.Game.Tests.Visual.DailyChallenge [Test] public void TestDailyChallenge() { - startChallenge(1234); + startChallenge(); AddStep("push screen", () => LoadScreen(new DailyChallengeIntro(room))); } [Test] public void TestPlayIntroOnceFlag() { - startChallenge(1234); + startChallenge(); AddStep("set intro played flag", () => Dependencies.Get().SetValue(Static.DailyChallengeIntroPlayed, true)); - startChallenge(1235); + startChallenge(); AddAssert("intro played flag reset", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.False); @@ -62,13 +62,12 @@ namespace osu.Game.Tests.Visual.DailyChallenge AddUntilStep("intro played flag set", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.True); } - private void startChallenge(int roomId) + private void startChallenge() { AddStep("add room", () => { API.Perform(new CreateRoomRequest(room = new Room { - RoomID = roomId, Name = "Daily Challenge: June 4, 2024", Playlist = [ @@ -83,7 +82,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge Category = RoomCategory.DailyChallenge })); }); - AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = roomId })); + AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = room.RoomID!.Value })); } } } diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs index b04696aded..b4e1ffffdb 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs @@ -13,6 +13,7 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.DailyChallenge { @@ -65,7 +66,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); breakdown.AddNewScore(ev); @@ -85,7 +86,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); breakdown.AddNewScore(ev); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs index ae212f5212..4619fad938 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); totals.AddNewScore(ev); @@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(11, 1000)); var testScore = TestResources.CreateTestScoreInfo(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs new file mode 100644 index 0000000000..f83d424d56 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Screens.Edit.Submission; +using osu.Game.Screens.Footer; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneBeatmapSubmissionOverlay : OsuTestScene + { + private ScreenFooter footer = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("add overlay", () => + { + var receptor = new ScreenFooter.BackReceptor(); + footer = new ScreenFooter(receptor); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new[] + { + (typeof(ScreenFooter), (object)footer), + (typeof(BeatmapSubmissionSettings), new BeatmapSubmissionSettings()), + }, + Children = new Drawable[] + { + receptor, + new BeatmapSubmissionOverlay + { + State = { Value = Visibility.Visible, }, + }, + footer, + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index fd3431c08b..6a9ca1292c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -615,6 +615,25 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); } + [Test] + public void TestUndoAfterQuickDeletingObjectWhileDragged() + { + AddStep("add hitobject", () => EditorBeatmap.Add( + new HitCircle { StartTime = 0, Position = new Vector2(200, 200) } + )); + + moveMouseToObject(() => EditorBeatmap.HitObjects[0]); + + AddStep("hold left click", () => InputManager.PressButton(MouseButton.Left)); + AddStep("drag hitobject to different position", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.BottomRight)); + AddStep("click middle mouse button", () => InputManager.Click(MouseButton.Middle)); + AddStep("release left click", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count, () => Is.Zero); + + AddStep("undo", () => Editor.Undo()); + AddAssert("one hitobject in beatmap", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); + } + [Test] public void TestShiftModifierMaintainsAspectRatio() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index c1a788cd22..fb57422e66 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -10,7 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Screens.Edit; @@ -115,7 +115,7 @@ namespace osu.Game.Tests.Visual.Editing public new int MaxIntervals => base.MaxIntervals; public TestDistanceSnapGrid(double? endTime = null) - : base(new HitObject(), grid_position, 0, endTime) + : base(grid_position, 0, endTime) { } @@ -181,7 +181,7 @@ namespace osu.Game.Tests.Visual.Editing } } - public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition) + public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition, double? fixedTime = null) => (Vector2.Zero, 0); } @@ -191,15 +191,13 @@ namespace osu.Game.Tests.Visual.Editing Bindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; - public float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) => beat_snap_distance; + public float GetBeatSnapDistance(IHasSliderVelocity withVelocity = null) => beat_snap_distance; - public float DurationToDistance(HitObject referenceObject, double duration) => (float)duration; + public float DurationToDistance(double duration, double timingReference, IHasSliderVelocity withVelocity = null) => (float)duration; - public double DistanceToDuration(HitObject referenceObject, float distance) => distance; + public double DistanceToDuration(float distance, double timingReference, IHasSliderVelocity withVelocity = null) => distance; - public double FindSnappedDuration(HitObject referenceObject, float distance) => 0; - - public float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) => 0; + public float FindSnappedDistance(float distance, double snapReferenceTime, IHasSliderVelocity withVelocity = null) => 0; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index ddf6502899..8d7eb41369 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -4,11 +4,13 @@ using System; using System.IO; using System.Linq; +using System.Text; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; +using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -28,6 +30,7 @@ using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Setup; +using osu.Game.Skinning; using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; @@ -91,6 +94,8 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.AsNonNull().ID)?.Value.DeletePending == true); + + AddUntilStep("wait for default beatmap", () => Editor.Beatmap.Value is DummyWorkingBeatmap); } [Test] @@ -99,44 +104,15 @@ namespace osu.Game.Tests.Visual.Editing AddStep("enter compose mode", () => InputManager.Key(Key.F1)); AddUntilStep("wait for timeline load", () => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddStep("enter setup mode", () => InputManager.Key(Key.F4)); + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual); - AddAssert("switch track to real track", () => - { - var setup = Editor.ChildrenOfType().First(); - - string temp = TestResources.GetTestBeatmapForImport(); - - string extractedFolder = $"{temp}_extracted"; - Directory.CreateDirectory(extractedFolder); - - try - { - using (var zip = ZipArchive.Open(temp)) - zip.WriteToDirectory(extractedFolder); - - bool success = setup.ChildrenOfType().First().ChangeAudioTrack(new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3"))); - - // ensure audio file is copied to beatmap as "audio.mp3" rather than original filename. - Assert.That(Beatmap.Value.Metadata.AudioFile == "audio.mp3"); - - return success; - } - finally - { - File.Delete(temp); - Directory.Delete(extractedFolder, true); - } - }); + AddAssert("switch track to real track", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3")); AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual); AddUntilStep("track length changed", () => Beatmap.Value.Track.Length > 60000); AddStep("test play", () => Editor.TestGameplay()); - AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); - AddStep("confirm save", () => InputManager.Key(Key.Number1)); - AddUntilStep("wait for return to editor", () => Editor.IsCurrentScreen()); AddAssert("track is still not virtual", () => Beatmap.Value.Track is not TrackVirtual); @@ -197,6 +173,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != firstDifficultyName; }); + ensureEditorLoaded(); + AddAssert("created difficulty has timing point", () => { var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); @@ -229,12 +207,21 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestCreateNewDifficultyWithScrollSpeed_SameRuleset() { - string firstDifficultyName = Guid.NewGuid().ToString(); + string previousDifficultyName = null!; + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = previousDifficultyName = Guid.NewGuid().ToString()); AddStep("save beatmap", () => Editor.Save()); AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); - AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != previousDifficultyName; + }); + + ensureEditorLoaded(); + + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = previousDifficultyName = Guid.NewGuid().ToString()); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); AddStep("add effect points", () => { @@ -245,8 +232,8 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 }); }); + ensureEditorLoaded(); AddStep("save beatmap", () => Editor.Save()); - AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); @@ -255,9 +242,11 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for created", () => { string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; - return difficultyName != null && difficultyName != firstDifficultyName; + return difficultyName != null && difficultyName != previousDifficultyName; }); + ensureEditorLoaded(); + AddAssert("created difficulty has timing point", () => { var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); @@ -285,6 +274,14 @@ namespace osu.Game.Tests.Visual.Editing AddStep("save beatmap", () => Editor.Save()); AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != firstDifficultyName; + }); + + ensureEditorLoaded(); + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); AddStep("add effect points", () => @@ -296,8 +293,8 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 }); }); + ensureEditorLoaded(); AddStep("save beatmap", () => Editor.Save()); - AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new TaikoRuleset().RulesetInfo)); AddUntilStep("wait for created", () => @@ -306,6 +303,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != firstDifficultyName; }); + ensureEditorLoaded(); + AddAssert("created difficulty has timing point", () => { var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); @@ -386,6 +385,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != originalDifficultyName; }); + ensureEditorLoaded(); + AddAssert("created difficulty has copy suffix in name", () => EditorBeatmap.BeatmapInfo.DifficultyName == copyDifficultyName); AddAssert("created difficulty has timing point", () => { @@ -396,7 +397,9 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4); AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2); + ensureEditorLoaded(); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); + AddAssert("online ID not copied", () => EditorBeatmap.BeatmapInfo.OnlineID == -1); AddStep("save beatmap", () => Editor.Save()); @@ -459,6 +462,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != originalDifficultyName; }); + ensureEditorLoaded(); + AddStep("save without changes", () => Editor.Save()); AddAssert("collection still points to old beatmap", () => !collection.BeatmapMD5Hashes.Contains(EditorBeatmap.BeatmapInfo.MD5Hash) @@ -496,6 +501,9 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != "New Difficulty"; }); + + ensureEditorLoaded(); + AddAssert("new difficulty has correct name", () => EditorBeatmap.BeatmapInfo.DifficultyName == "New Difficulty (1)"); AddAssert("new difficulty persisted", () => { @@ -533,6 +541,8 @@ namespace osu.Game.Tests.Visual.Editing return difficultyName != null && difficultyName != duplicate_difficulty_name; }); + ensureEditorLoaded(); + AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = duplicate_difficulty_name); AddStep("try to save beatmap", () => Editor.Save()); AddAssert("beatmap set not corrupted", () => @@ -559,6 +569,8 @@ namespace osu.Game.Tests.Visual.Editing return set != null && set.PerformRead(s => s.Beatmaps.Count == 1 && s.Files.Count == 1); }); + ensureEditorLoaded(); + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo)); AddUntilStep("wait for created", () => @@ -566,7 +578,8 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != duplicate_difficulty_name; }); - AddUntilStep("wait for editor load", () => Editor.IsLoaded && DialogOverlay.IsLoaded); + + ensureEditorLoaded(); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { @@ -603,6 +616,9 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName == "New Difficulty"; }); + + ensureEditorLoaded(); + AddAssert("new difficulty persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); @@ -621,6 +637,8 @@ namespace osu.Game.Tests.Visual.Editing StartTime = 1000 } })); + + ensureEditorLoaded(); AddStep("save beatmap", () => Editor.Save()); AddStep("try to create new catch difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo)); @@ -629,11 +647,240 @@ namespace osu.Game.Tests.Visual.Editing string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName == "New Difficulty (1)"; }); + + ensureEditorLoaded(); + AddAssert("new difficulty persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 3 && s.Files.Count == 3); }); } + + [Test] + public void TestSingleBackgroundFile() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set background", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg")); + + createNewDifficulty(); + createNewDifficulty(); + + switchToDifficulty(1); + + AddAssert("set background on second diff only", () => setBackground(applyToAllDifficulties: false, expected: "bg (1).jpg")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg")); + + switchToDifficulty(0); + + AddAssert("set background on first diff only", () => setBackground(applyToAllDifficulties: false, expected: "bg (2).jpg")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (2).jpg")); + + AddAssert("set background on all diff", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg")); + AddAssert("all diff uses one background", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.BackgroundFile == "bg.jpg")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg.jpg")); + AddAssert("other files removed", () => !Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg" || f.Filename == "bg (2).jpg")); + } + + [Test] + public void TestBackgroundFileChangesPreserveOnEncode() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set background", () => setBackground(applyToAllDifficulties: true, expected: "bg.jpg")); + + createNewDifficulty(); + createNewDifficulty(); + + switchToDifficulty(0); + + AddAssert("set different background on all diff", () => setBackgroundDifferentExtension(applyToAllDifficulties: true, expected: "bg.jpeg")); + AddAssert("all diff uses one background", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.BackgroundFile == "bg.jpeg")); + AddAssert("all diff encode same background", () => + { + return Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => + { + var files = new RealmFileStore(Realm, Dependencies.Get().Storage); + using var store = new RealmBackedResourceStore(b.BeatmapSet!.ToLive(Realm), files.Store, Realm); + string[] osu = Encoding.UTF8.GetString(store.Get(b.File!.Filename)).Split(Environment.NewLine); + Assert.That(osu, Does.Contain("0,0,\"bg.jpeg\",0,0")); + return true; + }); + }); + } + + [Test] + public void TestSingleAudioFile() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set audio", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3")); + + createNewDifficulty(); + createNewDifficulty(); + + switchToDifficulty(1); + + AddAssert("set audio on second diff only", () => setAudio(applyToAllDifficulties: false, expected: "audio (1).mp3")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3")); + + switchToDifficulty(0); + + AddAssert("set audio on first diff only", () => setAudio(applyToAllDifficulties: false, expected: "audio (2).mp3")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (2).mp3")); + + AddAssert("set audio on all diff", () => setAudio(applyToAllDifficulties: true, expected: "audio.mp3")); + AddAssert("all diff uses one audio", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Metadata.AudioFile == "audio.mp3")); + AddAssert("file added", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio.mp3")); + AddAssert("other files removed", () => !Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3" || f.Filename == "audio (2).mp3")); + } + + [Test] + public void TestMultipleBackgroundFiles() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg.jpg")); + + createNewDifficulty(); + + AddAssert("new difficulty uses same background", () => Beatmap.Value.Metadata.BackgroundFile == "bg.jpg"); + AddAssert("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg (1).jpg")); + AddAssert("new difficulty uses new background", () => Beatmap.Value.Metadata.BackgroundFile == "bg (1).jpg"); + + switchToDifficulty(0); + + AddAssert("old difficulty uses old background", () => Beatmap.Value.Metadata.BackgroundFile == "bg.jpg"); + AddAssert("old background not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg.jpg")); + AddStep("set background", () => setBackground(applyToAllDifficulties: false, expected: "bg.jpg")); + AddAssert("other background not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "bg (1).jpg")); + } + + [Test] + public void TestMultipleAudioFiles() + { + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddAssert("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio.mp3")); + + createNewDifficulty(); + + AddAssert("new difficulty uses same audio", () => Beatmap.Value.Metadata.AudioFile == "audio.mp3"); + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any()); + AddAssert("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio (1).mp3")); + AddAssert("new difficulty uses new audio", () => Beatmap.Value.Metadata.AudioFile == "audio (1).mp3"); + + switchToDifficulty(0); + + AddAssert("old difficulty uses old audio", () => Beatmap.Value.Metadata.AudioFile == "audio.mp3"); + AddAssert("old audio not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio.mp3")); + AddStep("set audio", () => setAudio(applyToAllDifficulties: false, expected: "audio.mp3")); + AddAssert("other audio not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3")); + } + + private void ensureEditorLoaded() => AddUntilStep("wait for editor load", () => Editor.ReadyForUse && DialogOverlay.IsLoaded); + + private void createNewDifficulty() + { + string? currentDifficulty = null; + + AddStep("save", () => Editor.Save()); + AddStep("create new difficulty", () => + { + currentDifficulty = EditorBeatmap.BeatmapInfo.DifficultyName; + Editor.CreateNewDifficulty(new OsuRuleset().RulesetInfo); + }); + + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); + AddStep("confirm creation with no objects", () => DialogOverlay.CurrentDialog!.PerformOkAction()); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != currentDifficulty; + }); + ensureEditorLoaded(); + + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any()); + } + + private void switchToDifficulty(int index) + { + AddStep("save", () => Editor.Save()); + AddStep($"switch to difficulty #{index + 1}", () => + Editor.SwitchToDifficulty(Beatmap.Value.BeatmapSetInfo.Beatmaps.ElementAt(index))); + + ensureEditorLoaded(); + AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup); + AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any()); + } + + private bool setBackground(bool applyToAllDifficulties, string expected) + { + var setup = Editor.ChildrenOfType().First(); + + return setFile(TestResources.GetQuickTestBeatmapForImport(), extractedFolder => + { + bool success = setup.ChildrenOfType().First().ChangeBackgroundImage( + new FileInfo(Path.Combine(extractedFolder, @"machinetop_background.jpg")), + applyToAllDifficulties); + + Assert.That(Beatmap.Value.Metadata.BackgroundFile, Is.EqualTo(expected)); + return success; + }); + } + + private bool setBackgroundDifferentExtension(bool applyToAllDifficulties, string expected) + { + var setup = Editor.ChildrenOfType().First(); + + return setFile(TestResources.GetQuickTestBeatmapForImport(), extractedFolder => + { + File.Move( + Path.Combine(extractedFolder, @"machinetop_background.jpg"), + Path.Combine(extractedFolder, @"machinetop_background.jpeg")); + + bool success = setup.ChildrenOfType().First().ChangeBackgroundImage( + new FileInfo(Path.Combine(extractedFolder, @"machinetop_background.jpeg")), + applyToAllDifficulties); + + Assert.That(Beatmap.Value.Metadata.BackgroundFile, Is.EqualTo(expected)); + return success; + }); + } + + private bool setAudio(bool applyToAllDifficulties, string expected) + { + var setup = Editor.ChildrenOfType().First(); + + return setFile(TestResources.GetTestBeatmapForImport(), extractedFolder => + { + bool success = setup.ChildrenOfType().First().ChangeAudioTrack( + new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3")), + applyToAllDifficulties); + + Assert.That(Beatmap.Value.Metadata.AudioFile, Is.EqualTo(expected)); + return success; + }); + } + + private bool setFile(string archivePath, Func func) + { + string temp = archivePath; + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + return func(extractedFolder); + } + finally + { + File.Delete(temp); + Directory.Delete(extractedFolder, true); + } + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index a766b253aa..ce9dbd5fb1 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); - AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == newTime); + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(newTime, null)); } [Test] @@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual.Editing [TestCase(true)] public void TestCopyPaste(bool deselectAfterCopy) { + const int paste_time = 2000; + var addedObject = new HitCircle { StartTime = 1000 }; AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); @@ -130,7 +132,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("copy hitobject", () => Editor.Copy()); - AddStep("move forward in time", () => EditorClock.Seek(2000)); + AddStep("move forward in time", () => EditorClock.Seek(paste_time)); if (deselectAfterCopy) { @@ -144,7 +146,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2); - AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000); + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(paste_time, null)); AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs new file mode 100644 index 0000000000..edaba67591 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +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; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneEditorClipboardSnapping : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + private const double beat_length = 60_000 / 180.0; // 180 bpm + private const double timing_point_time = 1500; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(timing_point_time, new TimingControlPoint { BeatLength = beat_length }); + return new TestBeatmap(ruleset, false) + { + ControlPointInfo = controlPointInfo + }; + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(6)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + public void TestPasteSnapping(int divisor) + { + const double paste_time = timing_point_time + 1271; // arbitrary timestamp that doesn't snap to the timing point at any divisor + + var addedObjects = new HitObject[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1200 }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + AddStep("select added objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + AddStep("copy hitobjects", () => Editor.Copy()); + + AddStep($"set beat divisor to 1/{divisor}", () => + { + var beatDivisor = (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor)); + beatDivisor.SetArbitraryDivisor(divisor); + }); + + AddStep("move forward in time", () => EditorClock.Seek(paste_time)); + AddAssert("not at snapped time", () => EditorClock.CurrentTime != EditorBeatmap.SnapTime(EditorClock.CurrentTime, null)); + + AddStep("paste hitobjects", () => Editor.Paste()); + + AddAssert("first object is snapped", () => Precision.AlmostEquals( + EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!.StartTime, + EditorBeatmap.ControlPointInfo.GetClosestSnappedTime(paste_time, divisor) + )); + + AddAssert("duration between pasted objects is same", () => + { + var firstObject = EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!; + var secondObject = EditorBeatmap.SelectedHitObjects.MaxBy(h => h.StartTime)!; + + return Precision.AlmostEquals(secondObject.StartTime - firstObject.StartTime, addedObjects[1].StartTime - addedObjects[0].StartTime); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs index 8b941d7597..092b2bc01c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs @@ -24,12 +24,7 @@ namespace osu.Game.Tests.Visual.Editing PoolableSkinnableSample[] loopingSamples = null; PoolableSkinnableSample[] onceOffSamples = null; - AddStep("get first slider", () => - { - slider = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First(); - onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray(); - loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray(); - }); + AddStep("get first slider", () => slider = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First()); AddStep("start playback", () => EditorClock.Start()); @@ -38,6 +33,9 @@ namespace osu.Game.Tests.Visual.Editing if (!slider.Tracking.Value) return false; + onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray(); + loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray(); + if (!loopingSamples.Any(s => s.Playing)) return false; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index d1782da25f..7f40da5bab 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -15,7 +15,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -190,7 +190,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Set tags again", () => EditorBeatmap.BeatmapInfo.Metadata.Tags = tags_to_discard); AddStep("Exit editor", () => Editor.Exit()); - AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddAssert("Tags reverted correctly", () => Game.Beatmap.Value.BeatmapInfo.Metadata.Tags == tags_to_save); } @@ -208,5 +208,11 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Beatmap still has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(7)); AddAssert("Correct beat divisor actually active", () => Editor.BeatDivisor, () => Is.EqualTo(7)); } + + [Test] + public void TestBeatmapVersionPopulatedCorrectly() + { + AddAssert("beatmap version is populated", () => EditorBeatmap.BeatmapVersion > 0); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 23efb40d3f..f65a3e67e8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -7,18 +7,18 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; +using osu.Game.Screens; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Play; @@ -42,14 +42,6 @@ namespace osu.Game.Tests.Visual.Editing private BeatmapSetInfo importedBeatmapSet; - private Bindable editorDim; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - editorDim = config.GetBindable(OsuSetting.EditorDim); - } - public override void SetUpSteps() { AddStep("import test beatmap", () => importedBeatmapSet = BeatmapImportHelper.LoadOszIntoOsu(game).GetResultSafely()); @@ -80,15 +72,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); AddStep("exit player", () => editorPlayer.Exit()); AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); - AddUntilStep("background has correct params", () => - { - // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ - // due to the beatmap refetch logic ran on editor suspend. - // this test cares about checking the background belonging to the editor specifically, so check that using reference equality - // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). - var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); - return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0; - }); + AddUntilStep("background is correct", () => this.ChildrenOfType().Single().CurrentScreen is EditorBackgroundScreen); AddAssert("no mods selected", () => SelectedMods.Value.Count == 0); } @@ -113,20 +97,41 @@ namespace osu.Game.Tests.Visual.Editing AddStep("exit player", () => editorPlayer.Exit()); AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); - AddUntilStep("background has correct params", () => - { - // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ - // due to the beatmap refetch logic ran on editor suspend. - // this test cares about checking the background belonging to the editor specifically, so check that using reference equality - // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). - var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); - return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0; - }); + AddUntilStep("background is correct", () => this.ChildrenOfType().Single().CurrentScreen is EditorBackgroundScreen); AddStep("start track", () => EditorClock.Start()); AddAssert("sample playback re-enabled", () => !Editor.SamplePlaybackDisabled.Value); } + [Test] + public void TestGameplayTestResetsPlaybackSpeedAdjustment() + { + AddStep("start track", () => EditorClock.Start()); + AddStep("change playback speed", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("track playback rate is 0.25x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(0.25)); + + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + AddAssert("editor track stopped", () => !EditorClock.IsRunning); + AddAssert("track playback rate is 1x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(1)); + + AddStep("exit player", () => editorPlayer.Exit()); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + AddAssert("track playback rate is 0.25x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(0.25)); + } + [TestCase(2000)] // chosen to be after last object in the map [TestCase(22000)] // chosen to be in the middle of the last spinner public void TestGameplayTestAtEndOfBeatmap(int offsetFromEnd) @@ -177,6 +182,7 @@ namespace osu.Game.Tests.Visual.Editing // bit of a hack to ensure this test can be ran multiple times without running into UNIQUE constraint failures AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = Guid.NewGuid().ToString()); + AddStep("start playing track", () => InputManager.Key(Key.Space)); AddStep("click test gameplay button", () => { var button = Editor.ChildrenOfType().Single(); @@ -185,11 +191,13 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Click(MouseButton.Left); }); AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveRequiredPopupDialog); + AddAssert("track stopped", () => !Beatmap.Value.Track.IsRunning); AddStep("save changes", () => DialogOverlay.CurrentDialog!.PerformOkAction()); EditorPlayer editorPlayer = null; AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + AddUntilStep("track playing", () => Beatmap.Value.Track.IsRunning); AddAssert("beatmap has 1 object", () => editorPlayer.Beatmap.Value.Beatmap.HitObjects.Count == 1); AddUntilStep("wait for return to editor", () => Stack.CurrentScreen is Editor); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs index 7f9a69833c..636b3f54d8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs @@ -4,6 +4,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Extensions; +using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Tests.Resources; @@ -25,13 +26,16 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestLocallyModifyingOnlineBeatmap() { + string initialHash = string.Empty; AddAssert("editor beatmap has online ID", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.GreaterThan(0)); + AddStep("store hash for later", () => initialHash = EditorBeatmap.BeatmapInfo.MD5Hash); AddStep("delete first hitobject", () => EditorBeatmap.RemoveAt(0)); SaveEditor(); ReloadEditorToSameBeatmap(); - AddAssert("editor beatmap online ID reset", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.EqualTo(-1)); + AddAssert("beatmap marked as locally modified", () => EditorBeatmap.BeatmapInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified)); + AddAssert("beatmap hash changed", () => EditorBeatmap.BeatmapInfo.MD5Hash, () => Is.Not.EqualTo(initialHash)); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs index 743529d40c..995acd28dd 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs @@ -65,10 +65,10 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Keys(PlatformAction.Paste); }); - assertArtistMetadata("Example Artist"); + assertArtistMetadata("Example ArtistExample Artist"); // It's important values are committed immediately on focus loss so the editor exit sequence detects them. - AddAssert("value immediately changed on focus loss", () => + AddAssert("value still changed after focus loss", () => { ((IFocusManager)InputManager).TriggerFocusContention(metadataSection); return editorBeatmap.Metadata.Artist; @@ -104,7 +104,7 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Keys(PlatformAction.Paste); }); - assertArtistMetadata("Example Artist"); + assertArtistMetadata("Example ArtistExample Artist"); AddStep("commit", () => InputManager.Key(Key.Enter)); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs index 955ded97af..e3b79d4053 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs @@ -14,7 +14,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Editing @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.Editing () => Is.EqualTo(1)); AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); addStepClickLink("00:00:000 (1)", waitForSeek: false); AddUntilStep("received 'must be in edit'", @@ -151,12 +151,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Present beatmap", () => Game.PresentBeatmap(beatmapSet)); AddUntilStep("Wait for song select", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.BeatmapSetsLoaded + && Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect + && songSelect.CarouselItemsPresented ); AddStep("Switch ruleset", () => Game.Ruleset.Value = ruleset); AddStep("Open editor for ruleset", () => - ((PlaySongSelect)Game.ScreenStack.CurrentScreen) + ((SoloSongSelect)Game.ScreenStack.CurrentScreen) .Edit(beatmapSet.Beatmaps.Last(beatmap => beatmap.Ruleset.Name == ruleset.Name)) ); AddUntilStep("Wait for editor open", () => editor?.ReadyForUse == true); diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs index 966e6513bb..ae20f5e5cf 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs @@ -7,6 +7,7 @@ using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; @@ -14,8 +15,10 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Tests.Beatmaps; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -58,23 +61,66 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestContextMenu() + public void TestRightClickDuringEmptyPlacementTogglesNewCombo() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + + AddStep("move mouse away from placed circle", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + Vector2.One)); + + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + AddAssert("new combo true", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.True)); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + } + + [Test] + public void TestRightClickDuringPlacementDeletes() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + + AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Has.Exactly(0).Items); + AddAssert("circle not selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Exactly(0).Items); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); + } + + [Test] + public void TestRightClickDuringSelectionShowsContextMenu() { AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); AddStep("place circle", () => InputManager.Click(MouseButton.Left)); - AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); - AddStep("delete with right mouse", () => - { - InputManager.Click(MouseButton.Right); - }); - AddAssert("circle not removed", () => EditorBeatmap.HitObjects, () => Has.One.Items); + // ensure the circle we're selecting is not a new combo so we can assert + // new combo doesn't happen to get toggled by right click. + AddStep("seek forward", () => EditorClock.Seek(1000)); + AddStep("place second circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("two circles added", () => EditorBeatmap.HitObjects, () => Has.Exactly(2).Items); + AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent)); + + AddStep("select selection tool", () => InputManager.Key(Key.Number1)); + AddStep("click right mouse", () => InputManager.Click(MouseButton.Right)); + + AddAssert("circle not removed", () => EditorBeatmap.HitObjects, () => Has.Exactly(2).Items); AddAssert("circle selected", () => EditorBeatmap.SelectedHitObjects, () => Has.One.Items); + AddAssert("context menu visible", () => Editor.ChildrenOfType().Any(c => c.IsPresent)); + AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False)); } [Test] - [Solo] public void TestCommitPlacementViaRightClick() { Playfield playfield = null!; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs new file mode 100644 index 0000000000..2dc9077a14 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs @@ -0,0 +1,186 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Submission; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneSubmissionStageProgress : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Resolved] + private AudioManager audio { get; set; } = null!; + + private Sample? completeSample; + + [Test] + public void TestAppearance() + { + float incrementingProgress = 0; + + SubmissionStageProgress progress = null!; + + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = progress = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Frobnicating the foobarator...", + } + }); + AddStep("not started", () => progress.SetNotStarted()); + AddStep("indeterminate progress", () => progress.SetInProgress()); + AddStep("increase progress to 100", () => + { + incrementingProgress = 0; + + ScheduledDelegate? task = null; + + task = Scheduler.AddDelayed(() => + { + if (incrementingProgress >= 1) + { + // ReSharper disable once AccessToModifiedClosure + task?.Cancel(); + return; + } + + if (RNG.NextDouble() < 0.01) + progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f)); + }, 0, true); + }); + AddStep("increase progress slowly then fail", () => + { + incrementingProgress = 0; + + ScheduledDelegate? task = null; + + task = Scheduler.AddDelayed(() => + { + if (incrementingProgress >= 1) + { + progress.SetFailed("nope"); + // ReSharper disable once AccessToModifiedClosure + task?.Cancel(); + return; + } + + progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.001f)); + }, 0, true); + }); + + AddUntilStep("wait for completed", () => incrementingProgress >= 1); + AddStep("completed", () => progress.SetCompleted()); + AddStep("failed", () => progress.SetFailed("the foobarator has defrobnicated")); + AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe")); + AddStep("canceled", () => progress.SetCanceled()); + } + + [Test] + public void TestAudioSequence() + { + SubmissionStageProgress[] stages = new SubmissionStageProgress[4]; + Container? cardContainer = null; + + AddStep("prepare", () => + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + stages[0] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Export...", + StageIndex = 0 + }, + stages[1] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "CreateSet...", + StageIndex = 1 + }, + stages[2] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Upload...", + StageIndex = 2 + }, + stages[3] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Update...", + StageIndex = 3 + }, + cardContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + } + }; + + completeSample = audio.Samples.Get(@"UI/bss-complete"); + }); + + for (int i = 0; i < stages.Length; i++) + { + int step = i; + AddStep($"{step}: not started", () => stages[step].SetNotStarted()); + AddStep($"{step}: indeterminate progress", () => stages[step].SetInProgress()); + AddStep($"{step}: 25% progress", () => stages[step].SetInProgress(0.25f)); + AddStep($"{step}: 70% progress", () => stages[step].SetInProgress(0.7f)); + AddStep($"{step}: completed", () => stages[step].SetCompleted()); + } + + AddWaitStep("pause for timing", 2); + + AddStep("Sequence Complete", () => + { + var beatmapSet = CreateAPIBeatmapSet(Ruleset.Value); + beatmapSet.Beatmaps = Enumerable.Repeat(beatmapSet.Beatmaps.First(), 100).ToArray(); + LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded => + { + cardContainer?.Add(loaded); + completeSample?.Play(); + }); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index cf07ce2431..eecfb7cb6e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -32,6 +32,7 @@ namespace osu.Game.Tests.Visual.Editing private TimingScreen timingScreen; private EditorBeatmap editorBeatmap; + private BeatmapEditorChangeHandler changeHandler; protected override bool ScrollUsingMouseWheel => false; @@ -46,6 +47,7 @@ namespace osu.Game.Tests.Visual.Editing private void reloadEditorBeatmap() { editorBeatmap = new EditorBeatmap(Beatmap.Value.GetPlayableBeatmap(Ruleset.Value)); + changeHandler = new BeatmapEditorChangeHandler(editorBeatmap); Child = new DependencyProvidingContainer { @@ -53,6 +55,7 @@ namespace osu.Game.Tests.Visual.Editing CachedDependencies = new (Type, object)[] { (typeof(EditorBeatmap), editorBeatmap), + (typeof(IEditorChangeHandler), changeHandler), (typeof(IBeatSnapProvider), editorBeatmap) }, Child = timingScreen = new TimingScreen @@ -72,8 +75,10 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("Wait for rows to load", () => Child.ChildrenOfType().Any()); } + // TODO: this is best-effort for now, but the comment out test below should probably be how things should work. + // Was originally working as of https://github.com/ppy/osu/pull/26141; Regressed at some point. [Test] - public void TestSelectedRetainedOverUndo() + public void TestSelectionDismissedOnUndo() { AddStep("Select first timing point", () => { @@ -95,25 +100,52 @@ namespace osu.Game.Tests.Visual.Editing return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; }); - AddStep("simulate undo", () => - { - var clone = editorBeatmap.ControlPointInfo.DeepClone(); + AddStep("undo", () => changeHandler?.RestoreState(-1)); - editorBeatmap.ControlPointInfo.Clear(); - - foreach (var group in clone.Groups) - { - foreach (var cp in group.ControlPoints) - editorBeatmap.ControlPointInfo.Add(group.Time, cp); - } - }); - - AddUntilStep("selection retained", () => - { - return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; - }); + AddUntilStep("selection dismissed", () => timingScreen.SelectedGroup.Value, () => Is.Null); } + // [Test] + // public void TestSelectedRetainedOverUndo() + // { + // AddStep("Select first timing point", () => + // { + // InputManager.MoveMouseTo(Child.ChildrenOfType().First()); + // InputManager.Click(MouseButton.Left); + // }); + // + // AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 2170); + // AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 2170); + // + // AddStep("Adjust offset", () => + // { + // InputManager.MoveMouseTo(timingScreen.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre + new Vector2(20, 0)); + // InputManager.Click(MouseButton.Left); + // }); + // + // AddUntilStep("wait for offset changed", () => + // { + // return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; + // }); + // + // AddStep("undo", () => changeHandler?.RestoreState(-1)); + // + // AddUntilStep("selection retained", () => + // { + // return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170; + // }); + // + // AddAssert("check group count", () => editorBeatmap.ControlPointInfo.Groups.Count, () => Is.EqualTo(10)); + // + // AddStep("Adjust offset", () => + // { + // InputManager.MoveMouseTo(timingScreen.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre + new Vector2(20, 0)); + // InputManager.Click(MouseButton.Left); + // }); + // + // AddAssert("check group count", () => editorBeatmap.ControlPointInfo.Groups.Count, () => Is.EqualTo(10)); + // } + [Test] public void TestScrollControlGroupIntoView() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index 1c8a18e131..2c84e76b2e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -128,12 +128,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Press alt down", () => InputManager.PressKey(Key.AltLeft)); AddStep("Scroll by 3", () => InputManager.ScrollBy(new Vector2(0, 3))); AddAssert("Box not at 0", () => !Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft)); - AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X)); + AddAssert("Box 1/2 at 1/2", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.5f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.5f * scrollQuad.Size.X)); // Scroll out at 0.25 AddStep("Scroll by -3", () => InputManager.ScrollBy(new Vector2(0, -3))); AddAssert("Box at 0", () => Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft)); - AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X)); + AddAssert("Box 1/2 at 1/2", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.5f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.5f * scrollQuad.Size.X)); AddStep("Release alt", () => InputManager.ReleaseKey(Key.AltLeft)); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonJudgementCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonJudgementCounter.cs new file mode 100644 index 0000000000..e5886aa607 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonJudgementCounter.cs @@ -0,0 +1,209 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +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.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.HUD.JudgementCounter; +using osu.Game.Skinning.Components; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneArgonJudgementCounter : OsuTestScene + { + private ScoreProcessor scoreProcessor = null!; + private JudgementCountController judgementCountController = null!; + private TestArgonJudgementCounterDisplay counterDisplay = null!; + + private DependencyProvidingContainer content = null!; + + protected override Container Content => content; + + private readonly Bindable lastJudgementResult = new Bindable(); + + private int iteration; + + [SetUpSteps] + public void SetUpSteps() => AddStep("Create components", () => + { + var ruleset = CreateRuleset(); + + Debug.Assert(ruleset != null); + + scoreProcessor = new ScoreProcessor(ruleset); + base.Content.Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(ScoreProcessor), scoreProcessor), (typeof(Ruleset), ruleset) }, + Children = new Drawable[] + { + judgementCountController = new JudgementCountController(), + content = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(JudgementCountController), judgementCountController) }, + } + }, + }; + }); + + protected override Ruleset CreateRuleset() => new OsuRuleset(); + + private void applyOneJudgement(HitResult result) + { + lastJudgementResult.Value = new OsuJudgementResult(new HitObject + { + StartTime = iteration * 10000 + }, new OsuJudgement()) + { + Type = result, + }; + scoreProcessor.ApplyResult(lastJudgementResult.Value); + + iteration++; + } + + [Test] + public void TestAddJudgementsToCounters() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.Great), 2); + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.Miss), 2); + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.Meh), 2); + } + + [Test] + public void TestAddWhilstHidden() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.LargeTickHit), 2); + AddAssert("Check value added whilst hidden", () => hiddenCount() == 2); + AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All); + } + + [Test] + public void TestChangeFlowDirection() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + + AddStep("Set direction vertical", () => counterDisplay.FlowDirection.Value = Direction.Vertical); + AddStep("Set direction horizontal", () => counterDisplay.FlowDirection.Value = Direction.Horizontal); + + AddStep("add 100 ok judgements", () => + { + for (int i = 0; i < 100; i++) + applyOneJudgement(HitResult.Ok); + }); + AddStep("add 1000 great judgements", () => + { + for (int i = 0; i < 1000; i++) + applyOneJudgement(HitResult.Great); + }); + + AddToggleStep("toggle max judgement display", t => counterDisplay.ShowMaxJudgement.Value = t); + } + + [Test] + public void TestToggleJudgementNames() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + + AddStep("Show label", () => counterDisplay.ShowLabel.Value = true); + AddWaitStep("wait some", 2); + AddAssert("Assert hidden", () => counterDisplay.CounterFlow.Children.First().Alpha == 1); + AddStep("Hide label", () => counterDisplay.ShowLabel.Value = false); + AddWaitStep("wait some", 2); + AddAssert("Assert shown", () => counterDisplay.CounterFlow.Children.First().Alpha == 1); + } + + [Test] + public void TestHideMaxValue() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + + AddStep("Hide max judgement", () => counterDisplay.ShowMaxJudgement.Value = false); + AddWaitStep("wait some", 2); + AddAssert("Check max hidden", () => counterDisplay.CounterFlow.ChildrenOfType().First().Alpha == 0); + AddStep("Show max judgement", () => counterDisplay.ShowMaxJudgement.Value = true); + } + + [Test] + public void TestMaxValueStartsHidden() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay + { + ShowMaxJudgement = { Value = false } + }); + AddAssert("Check max hidden", () => counterDisplay.CounterFlow.ChildrenOfType().First().Alpha == 0); + } + + [Test] + public void TestMaxValueHiddenOnModeChange() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + + AddStep("Set max judgement to hide itself", () => counterDisplay.ShowMaxJudgement.Value = false); + AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All); + AddWaitStep("wait some", 2); + AddAssert("Assert max judgement hidden", () => counterDisplay.CounterFlow.ChildrenOfType().First().Alpha == 0); + } + + [Test] + public void TestNoDuplicates() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All); + AddAssert("Check no duplicates", + () => counterDisplay.CounterFlow.ChildrenOfType().Count(), + () => Is.EqualTo(counterDisplay.CounterFlow.ChildrenOfType().Select(c => c.Result.DisplayName).Distinct().Count())); + } + + [Test] + public void TestCycleDisplayModes() + { + AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay()); + + AddStep("Show basic judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.Simple); + AddWaitStep("wait some", 2); + AddAssert("Check only basic", () => counterDisplay.CounterFlow.ChildrenOfType().Last().Alpha == 0); + AddStep("Show normal judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.Normal); + AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All); + AddWaitStep("wait some", 2); + AddAssert("Check all visible", () => counterDisplay.CounterFlow.ChildrenOfType().Last().Alpha == 1); + AddToggleStep("toggle wireframe display", t => counterDisplay.WireframeOpacity.Value = t ? 0.3f : 0); + AddStep("Set direction vertical", () => counterDisplay.FlowDirection.Value = Direction.Vertical); + AddStep("Set direction horizontal", () => counterDisplay.FlowDirection.Value = Direction.Horizontal); + } + + private int hiddenCount() + { + var num = counterDisplay.CounterFlow.Children.First(child => child.Result.Types.Contains(HitResult.LargeTickHit)); + return num.Result.ResultCount.Value; + } + + private partial class TestArgonJudgementCounterDisplay : ArgonJudgementCounterDisplay + { + public new FillFlowContainer CounterFlow => base.CounterFlow; + + public TestArgonJudgementCounterDisplay() + { + Margin = new MarginPadding { Top = 100 }; + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index 0f47c3cd27..adbfebbfc6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -4,12 +4,16 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; @@ -23,22 +27,20 @@ namespace osu.Game.Tests.Visual.Gameplay public partial class TestSceneBeatmapOffsetControl : OsuTestScene { private BeatmapOffsetControl offsetControl = null!; + private OsuConfigManager localConfig = null!; + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage)); + } [SetUpSteps] public void SetUpSteps() { - AddStep("Create control", () => - { - Child = new PlayerSettingsGroup("Some settings") - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - offsetControl = new BeatmapOffsetControl() - } - }; - }); + AddStep("reset settings", () => localConfig.SetValue(OsuSetting.AutomaticallyAdjustBeatmapOffset, false)); + + recreateControl(); } [Test] @@ -56,26 +58,60 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } + /// + /// If we already have an old score with enough hit events and the new score doesn't have enough, continue displaying the old one rather than showing the user "play too short" message. + /// + [Test] + public void TestTooShortToDisplay_HasPreviousValidScore() + { + const double average_error = -4.5; + const double initial_offset = -2; + + AddStep("Set offset non-neutral", () => offsetControl.Current.Value = initial_offset); + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + + AddStep("Set reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any()); + + AddStep("Set short reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0, 2), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddUntilStep("Still calibration button", () => offsetControl.ChildrenOfType().Any()); + + AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); + AddAssert("Offset is adjusted", () => offsetControl.Current.Value == initial_offset - average_error); + } + [Test] public void TestNotEnoughTimedHitEvents() { AddStep("Set short reference score", () => { + // 50 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows List hitEvents = [ - // 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), ]; + for (int i = 0; i < 49; i++) + { + hitEvents.Add(new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null)); + } + foreach (var ev in hitEvents) ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -123,13 +159,14 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestCalibrationFromZero() { + ScoreInfo referenceScore = null!; const double average_error = -4.5; AddAssert("Offset is neutral", () => offsetControl.Current.Value == 0); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); AddStep("Set reference score", () => { - offsetControl.ReferenceScore.Value = new ScoreInfo + offsetControl.ReferenceScore.Value = referenceScore = new ScoreInfo { HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error), BeatmapInfo = Beatmap.Value.BeatmapInfo, @@ -137,11 +174,13 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any()); + AddAssert("Offset is still neutral", () => offsetControl.Current.Value == 0); AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error); - AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); - AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); + + recreateControl(); + AddStep("Set same reference score", () => offsetControl.ReferenceScore.Value = referenceScore); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } @@ -151,6 +190,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestCalibrationFromNonZero() { + ScoreInfo referenceScore = null!; const double average_error = -4.5; const double initial_offset = -2; @@ -158,7 +198,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); AddStep("Set reference score", () => { - offsetControl.ReferenceScore.Value = new ScoreInfo + offsetControl.ReferenceScore.Value = referenceScore = new ScoreInfo { HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error), BeatmapInfo = Beatmap.Value.BeatmapInfo, @@ -166,11 +206,13 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any()); + AddAssert("Offset still not adjusted", () => offsetControl.Current.Value == initial_offset); AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); AddAssert("Offset is adjusted", () => offsetControl.Current.Value == initial_offset - average_error); - AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); - AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); + + recreateControl(); + AddStep("Set same reference score", () => offsetControl.ReferenceScore.Value = referenceScore); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } @@ -217,12 +259,10 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any()); + AddAssert("Offset still not adjusted", () => offsetControl.Current.Value == initial_offset); AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); AddAssert("Offset is adjusted", () => offsetControl.Current.Value, () => Is.EqualTo(initial_offset - average_error)); - AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); - AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); - AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); AddStep("Clean up beatmap", () => Realm.Write(r => r.RemoveAll())); } @@ -246,10 +286,106 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any()); AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error); - AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); - AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); - AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + } + + [Test] + public void TestAutomaticAdjustment() + { + const double average_error = -4.5; + + AddStep("enable automatic adjust", () => localConfig.SetValue(OsuSetting.AutomaticallyAdjustBeatmapOffset, true)); + AddAssert("offset zero", () => offsetControl.Current.Value == 0); + + AddStep("set reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddAssert("no calibration button", () => !offsetControl.ChildrenOfType().Any(b => b.IsPresent)); + AddAssert("offset adjustment text displayed", () => offsetControl.ChildrenOfType().Any(t => t.Text.ToString().Contains("adjusted"))); + AddAssert("offset adjusted", () => offsetControl.Current.Value == -average_error); + + AddStep("set reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddAssert("no calibration button", () => !offsetControl.ChildrenOfType().Any(b => b.IsPresent)); + AddAssert("offset adjustment text not displayed", () => !offsetControl.ChildrenOfType().Any(t => t.Text.ToString().Contains("adjusted"))); + AddAssert("offset still", () => offsetControl.Current.Value == -average_error); + + AddStep("adjust offset manually", () => offsetControl.Current.Value = 0); + AddUntilStep("calibration button displayed", () => offsetControl.ChildrenOfType().Any()); + + AddStep("press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); + AddAssert("offset adjusted", () => offsetControl.Current.Value == -average_error); + AddUntilStep("button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); + } + + [Test] + public void TestAutomaticAdjustmentWithUnstableRate() + { + const double average_error = -25; + const int spread = 25; + const double expected_offset = 12.9; // due to high UR (~147). see BeatmapOffsetControl.computeSuggestedOffset() + + AddStep("enable automatic adjust", () => localConfig.SetValue(OsuSetting.AutomaticallyAdjustBeatmapOffset, true)); + AddAssert("offset zero", () => offsetControl.Current.Value == 0); + + AddStep("set reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + // distribute the hit events such that it produces ~147 UR. setup taken from UnstableRateTest. + HitEvents = Enumerable.Range((int)average_error - spread, spread * 2 + 1) + .Select(t => new HitEvent(t, 1.0, HitResult.Great, new HitObject(), null, null)) + .ToList(), + + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddAssert("no calibration button", () => !offsetControl.ChildrenOfType().Any(b => b.IsPresent)); + AddAssert("offset adjustment text displayed", () => offsetControl.ChildrenOfType().Any(t => t.Text.ToString().Contains("adjusted"))); + AddAssert("offset adjusted", () => offsetControl.Current.Value == expected_offset); + + AddStep("adjust offset manually", () => offsetControl.Current.Value = 0); + AddUntilStep("calibration button displayed", () => offsetControl.ChildrenOfType().Any()); + + AddStep("press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); + AddAssert("offset adjusted", () => offsetControl.Current.Value == expected_offset); + AddUntilStep("button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); + } + + [Test] + public void TestNegativeZero() + { + AddAssert("assert", () => BeatmapOffsetControl.GetOffsetExplanatoryText(-0.0001).ToString(), () => Is.EqualTo("0 ms")); + } + + private void recreateControl() + { + AddStep("Create control", () => + { + Child = new PlayerSettingsGroup("Some settings") + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + offsetControl = new BeatmapOffsetControl() + } + }; + }); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs index 21b6495865..844f5cba01 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs @@ -40,11 +40,16 @@ namespace osu.Game.Tests.Visual.Gameplay RelativeSizeAxes = Axes.Both, }, breakTracker = new TestBreakTracker(), - breakOverlay = new BreakOverlay(true, new ScoreProcessor(new OsuRuleset())) + breakOverlay = new BreakOverlay(new ScoreProcessor(new OsuRuleset())) { ProcessCustomClock = false, BreakTracker = breakTracker, - } + }, + new LetterboxOverlay + { + ProcessCustomClock = false, + BreakTracker = breakTracker, + }, }; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs index db06329d74..9c93eb375c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs @@ -108,7 +108,10 @@ namespace osu.Game.Tests.Visual.Gameplay public bool IsRunning => true; - public double TrueGameplayRate { set => adjustableAudioComponent.Tempo.Value = value; } + public double TrueGameplayRate + { + set => adjustableAudioComponent.Tempo.Value = value; + } private readonly AudioAdjustments adjustableAudioComponent = new AudioAdjustments(); @@ -120,6 +123,7 @@ namespace osu.Game.Tests.Visual.Gameplay public double FramesPerSecond => throw new NotImplementedException(); public FrameTimeInfo TimeInfo => throw new NotImplementedException(); public double StartTime => throw new NotImplementedException(); + public double GameplayStartTime => throw new NotImplementedException(); public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index c2999e3f5a..dfaebccf32 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -131,10 +131,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () => { - mainContainer.Child = new FrameStabilityContainer(gameplayStartTime) - { - AllowBackwardsSeeks = true, - }.WithChild(consumer = new ClockConsumingChild()); + mainContainer.Child = new FrameStabilityContainer(gameplayStartTime).WithChild(consumer = new ClockConsumingChild()); }); private void seekManualTo(double time) => AddStep($"seek manual clock to {time}", () => manualClock.CurrentTime = time); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 1787230117..45e14053e0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.PolygonExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,176 +16,222 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osuTK; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Gameplay; +using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { - [TestFixture] public partial class TestSceneGameplayLeaderboard : OsuTestScene { - private TestGameplayLeaderboard leaderboard; + private Box? blackBackground; + private DrawableGameplayLeaderboard leaderboard = null!; - private readonly BindableLong playerScore = new BindableLong(); + [Cached] + private readonly LeaderboardManager leaderboardManager = new LeaderboardManager(); + + [Cached] + private readonly GameplayState gameplayState; public TestSceneGameplayLeaderboard() { - AddStep("toggle expanded", () => + var localScore = new ScoreInfo { - if (leaderboard != null) - leaderboard.Expanded.Value = !leaderboard.Expanded.Value; + User = new APIUser { Username = "You", Id = 3 } + }; + + gameplayState = TestGameplayState.Create(new OsuRuleset(), null, new Score { ScoreInfo = localScore }, new Bindable(LocalUserPlayingState.Playing)); + } + + [BackgroundDependencyLoader] + private void load() + { + LoadComponent(leaderboardManager); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddStep("toggle collapsed", () => + { + if (leaderboard.IsNotNull()) + leaderboard.CollapseDuringGameplay.Value = !leaderboard.CollapseDuringGameplay.Value; }); - AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v); - } + AddStep("toggle black background", () => blackBackground?.FadeTo(1 - blackBackground.Alpha, 300, Easing.OutQuint)); - [Test] - public void TestLayoutWithManyScores() - { - createLeaderboard(); - - AddStep("add many scores in one go", () => + AddSliderStep("leaderboard width", 0, 800, 300, v => { - for (int i = 0; i < 32; i++) - createRandomScore(new APIUser { Username = $"Player {i + 1}" }); - - // Add player at end to force an animation down the whole list. - playerScore.Value = 0; - createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); + if (leaderboard.IsNotNull()) + leaderboard.Width = v; }); - // Gameplay leaderboard has custom scroll logic, which when coupled with LayoutDuration - // has caused layout to not work in the past. - - AddUntilStep("wait for fill flow layout", - () => leaderboard.ChildrenOfType>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad)); - - AddUntilStep("wait for some scores not masked away", - () => leaderboard.ChildrenOfType().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre))); - - AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); - - AddStep("change score to middle", () => playerScore.Value = 1000000); - AddWaitStep("wait for movement", 5); - AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); - - AddStep("change score to first", () => playerScore.Value = 5000000); - AddWaitStep("wait for movement", 5); - AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); - } - - [Test] - public void TestPlayerScore() - { - createLeaderboard(); - addLocalPlayer(); - - var player2Score = new BindableLong(1234567); - var player3Score = new BindableLong(1111111); - - AddStep("add player 2", () => createLeaderboardScore(player2Score, new APIUser { Username = "Player 2" })); - AddStep("add player 3", () => createLeaderboardScore(player3Score, new APIUser { Username = "Player 3" })); - - AddUntilStep("is player 2 position #1", () => leaderboard.CheckPositionByUsername("Player 2", 1)); - AddUntilStep("is player position #2", () => leaderboard.CheckPositionByUsername("You", 2)); - AddUntilStep("is player 3 position #3", () => leaderboard.CheckPositionByUsername("Player 3", 3)); - - AddStep("set score above player 3", () => player2Score.Value = playerScore.Value - 500); - AddUntilStep("is player position #1", () => leaderboard.CheckPositionByUsername("You", 1)); - AddUntilStep("is player 2 position #2", () => leaderboard.CheckPositionByUsername("Player 2", 2)); - AddUntilStep("is player 3 position #3", () => leaderboard.CheckPositionByUsername("Player 3", 3)); - - AddStep("set score below players", () => player2Score.Value = playerScore.Value - 123456); - AddUntilStep("is player position #1", () => leaderboard.CheckPositionByUsername("You", 1)); - AddUntilStep("is player 3 position #2", () => leaderboard.CheckPositionByUsername("Player 3", 2)); - AddUntilStep("is player 2 position #3", () => leaderboard.CheckPositionByUsername("Player 2", 3)); - } - - [Test] - public void TestRandomScores() - { - createLeaderboard(); - addLocalPlayer(); - - int playerNumber = 1; - AddRepeatStep("add player with random score", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 10); - } - - [Test] - public void TestExistingUsers() - { - createLeaderboard(); - addLocalPlayer(); - - AddStep("add peppy", () => createRandomScore(new APIUser { Username = "peppy", Id = 2 })); - AddStep("add smoogipoo", () => createRandomScore(new APIUser { Username = "smoogipoo", Id = 1040328 })); - AddStep("add flyte", () => createRandomScore(new APIUser { Username = "flyte", Id = 3103765 })); - AddStep("add frenzibyte", () => createRandomScore(new APIUser { Username = "frenzibyte", Id = 14210502 })); - } - - [Test] - public void TestMaxHeight() - { - createLeaderboard(); - addLocalPlayer(); - - int playerNumber = 1; - AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3); - checkHeight(4); - - AddRepeatStep("add 4 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 4); - checkHeight(8); - - AddRepeatStep("add 4 other players", () => createRandomScore(new APIUser { 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); - } - - [Test] - public void TestFriendScore() - { - APIUser friend = new APIUser { Username = "my friend", Id = 10000 }; - - createLeaderboard(); - addLocalPlayer(); - - AddStep("Add friend to API", () => + AddSliderStep("leaderboard height", 0, 1000, 300, v => { + if (leaderboard.IsNotNull()) + leaderboard.Height = v; + }); + + AddSliderStep("set player score", 50, 1_000_000, 700_000, v => gameplayState.ScoreProcessor.TotalScore.Value = v); + } + + [Test] + public void TestDisplay() + { + AddStep("set scores", () => + { + var friend = new APIUser { Username = "Friend", Id = 1337 }; + var api = (DummyAPIAccess)API; - api.Friends.Clear(); - api.Friends.Add(new APIRelation + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.Add(new APIRelation { Mutual = true, RelationType = RelationType.Friend, TargetID = friend.OnlineID, TargetUser = friend }); + + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[] + { + new ScoreInfo { User = new APIUser { Username = "Top", Id = 2 }, TotalScore = 900_000, Accuracy = 0.99, MaxCombo = 999 }, + new ScoreInfo { User = new APIUser { Username = "Second", Id = 14 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 }, + new ScoreInfo { User = friend, TotalScore = 700_000, Accuracy = 0.88, MaxCombo = 777 }, + }, scoresRequested: 50, totalScores: 3, null); }); - int playerNumber = 1; + createLeaderboard(); - AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3); - AddUntilStep("no pink color scores", - () => leaderboard.ChildrenOfType().Select(b => ((Colour4)b.Colour).ToHex()), - () => Does.Not.Contain("#FF549A")); - - AddRepeatStep("add 3 friend score", () => createRandomScore(friend), 3); - AddUntilStep("at least one friend score is pink", - () => leaderboard.GetAllScoresForUsername("my friend") - .SelectMany(score => score.ChildrenOfType()) - .Select(b => ((Colour4)b.Colour).ToHex()), - () => Does.Contain("#FF549A")); + AddStep("set score to 650k", () => gameplayState.ScoreProcessor.TotalScore.Value = 650_000); + AddUntilStep("wait for 4th spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(4)); + AddStep("set score to 750k", () => gameplayState.ScoreProcessor.TotalScore.Value = 750_000); + AddUntilStep("wait for 3rd spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(3)); + AddStep("set score to 850k", () => gameplayState.ScoreProcessor.TotalScore.Value = 850_000); + AddUntilStep("wait for 2nd spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(2)); + AddStep("set score to 950k", () => gameplayState.ScoreProcessor.TotalScore.Value = 950_000); + AddUntilStep("wait for 1st spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(1)); } - private void addLocalPlayer() + [Test] + public void TestLongScores() { - AddStep("add local player", () => + AddStep("set scores", () => { - playerScore.Value = 1222333; - createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); + var friend = new APIUser { Username = "Friend", Id = 1337 }; + + var api = (DummyAPIAccess)API; + + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.Add(new APIRelation + { + Mutual = true, + RelationType = RelationType.Friend, + TargetID = friend.OnlineID, + TargetUser = friend + }); + + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[] + { + new ScoreInfo { User = new APIUser { Username = "Top", Id = 2 }, TotalScore = 900_000_000, Accuracy = 0.99, MaxCombo = 999999 }, + new ScoreInfo { User = new APIUser { Username = "Second", Id = 14 }, TotalScore = 800_000_000, Accuracy = 0.9, MaxCombo = 888888 }, + new ScoreInfo { User = friend, TotalScore = 700_000_000, Accuracy = 0.88, MaxCombo = 777777 }, + }, scoresRequested: 50, totalScores: 3, null); + }); + + createLeaderboard(); + + AddStep("set score to 650k", () => gameplayState.ScoreProcessor.TotalScore.Value = 650_000_000); + AddUntilStep("wait for 4th spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(4)); + AddStep("set score to 750k", () => gameplayState.ScoreProcessor.TotalScore.Value = 750_000_000); + AddUntilStep("wait for 3rd spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(3)); + AddStep("set score to 850k", () => gameplayState.ScoreProcessor.TotalScore.Value = 850_000_000); + AddUntilStep("wait for 2nd spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(2)); + AddStep("set score to 950k", () => gameplayState.ScoreProcessor.TotalScore.Value = 950_000_000); + AddUntilStep("wait for 1st spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(1)); + } + + [Test] + public void TestLayoutWithManyScores() + { + AddStep("set scores", () => + { + var scores = new List(); + + for (int i = 0; i < 32; i++) + scores.Add(new ScoreInfo { User = new APIUser { Username = $"Player {i + 1}" }, TotalScore = RNG.Next(700_000, 1_000_000) }); + + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(scores, scoresRequested: 50, scores.Count, null); + gameplayState.ScoreProcessor.TotalScore.Value = 0; + }); + + createLeaderboard(); + + // Gameplay leaderboard has custom scroll logic, which when coupled with LayoutDuration + // has caused layout to not work in the past. + + AddUntilStep("wait for fill flow layout", + () => leaderboard.ChildrenOfType>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad)); + + AddUntilStep("wait for some scores not masked away", + () => leaderboard.ChildrenOfType().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre))); + + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); + + AddStep("change score to middle", () => gameplayState.ScoreProcessor.TotalScore.Value = 850_000); + AddWaitStep("wait for movement", 5); + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); + + AddStep("change score to first", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_000_000); + AddWaitStep("wait for movement", 5); + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); + } + + [Test] + public void TestExistingUsers() + { + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[] + { + new ScoreInfo { User = new APIUser { Username = "peppy", Id = 2 }, TotalScore = 900_000, Accuracy = 0.99, MaxCombo = 999 }, + new ScoreInfo { User = new APIUser { Username = "smoogipoo", Id = 1040328 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 }, + new ScoreInfo { User = new APIUser { Username = "flyte", Id = 3103765 }, TotalScore = 700_000, Accuracy = 0.9, MaxCombo = 888 }, + new ScoreInfo { User = new APIUser { Username = "frenzibyte", Id = 14210502 }, TotalScore = 600_000, Accuracy = 0.9, MaxCombo = 777 }, + }, scoresRequested: 50, totalScores: 4, null); + }); + + createLeaderboard(); + } + + [Test] + public void TestQuitScore() + { + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[] + { + new ScoreInfo { User = new APIUser { Username = "Quit", Id = 3 }, TotalScore = 100_000, Accuracy = 0.99, MaxCombo = 999 }, + }, scoresRequested: 50, totalScores: 1, null); + }); + + createLeaderboard(); + + AddStep("mark score as quit", () => + { + var quitScore = this.ChildrenOfType().Single().Scores.Single(s => s.User.Username == "Quit"); + quitScore.HasQuit.Value = true; }); } @@ -192,36 +239,33 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("create leaderboard", () => { - Child = leaderboard = new TestGameplayLeaderboard + SoloGameplayLeaderboardProvider soloGameplayLeaderboardProvider; + + Child = new DependencyProvidingContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(2), + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(IGameplayLeaderboardProvider), soloGameplayLeaderboardProvider = new SoloGameplayLeaderboardProvider()), + }, + Children = new Drawable[] + { + soloGameplayLeaderboardProvider, + blackBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0f, + }, + leaderboard = new DrawableGameplayLeaderboard + { + CollapseDuringGameplay = { Value = false }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } }; }); } - - private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableLong(RNG.Next(0, 5_000_000)), user); - - private void createLeaderboardScore(BindableLong score, APIUser user, bool isTracked = false) - { - var leaderboardScore = leaderboard.Add(user, isTracked); - leaderboardScore.TotalScore.BindTo(score); - } - - private partial class TestGameplayLeaderboard : GameplayLeaderboard - { - public float Spacing => Flow.Spacing.Y; - - public bool CheckPositionByUsername(string username, int? expectedPosition) - { - var scoreItem = Flow.FirstOrDefault(i => i.User?.Username == username); - - return scoreItem != null && scoreItem.ScorePosition == expectedPosition; - } - - public IEnumerable GetAllScoresForUsername(string username) - => Flow.Where(i => i.User?.Username == username); - } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index 21c83d521c..84b312d5ee 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -9,7 +9,6 @@ using osu.Game.Audio; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Screens.Play; using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay @@ -21,6 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay private bool seek; [Test] + [FlakyTest] public void TestAllSamplesStopDuringSeek() { DrawableSlider? slider = null; @@ -73,8 +73,8 @@ namespace osu.Game.Tests.Visual.Gameplay // // We want to keep seeking while asserting various test conditions, so // continue to seek until we unset the flag. - var gameplayClockContainer = Player.ChildrenOfType().First(); - gameplayClockContainer.Seek(gameplayClockContainer.CurrentTime > 30000 ? 0 : 60000); + var gameplayClockContainer = Player?.GameplayClockContainer; + gameplayClockContainer?.Seek(gameplayClockContainer.CurrentTime > 30000 ? 0 : 60000); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs index 6981591193..894b51ddcb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -65,30 +65,30 @@ namespace osu.Game.Tests.Visual.Gameplay { new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }, new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) } }, new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) }, }, new HitCircle { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, }, new Slider { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), StartTime = t += spacing, Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, Vector2.UnitY * 200 }), Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) }, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs new file mode 100644 index 0000000000..47791dd462 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +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.Rendering; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Skinning; +using osu.Game.Tests.Gameplay; +using osu.Game.Tests.Visual.Spectator; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [Description(@"Exercises the appearance of the HUD overlay on various skin and ruleset combinations.")] + public partial class TestSceneHUDOverlayRulesetLayouts : OsuTestScene, IStorageResourceProvider + { + private readonly Dictionary skins = new Dictionary(); + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + skins["argon"] = new ArgonSkin(this); + skins["triangles"] = new TrianglesSkin(this); + skins["legacy"] = new DefaultLegacySkin(this); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddToggleStep("toggle leaderboard", b => configManager.SetValue(OsuSetting.GameplayLeaderboard, b)); + } + + [Test] + public void TestLayout( + [Values("argon", "triangles", "legacy")] + string skinName, + [Values("osu", "taiko", "fruits", "mania")] + string rulesetName) + { + AddStep("create content", () => + { + var rulesetInfo = rulesets.GetRuleset(rulesetName); + var ruleset = rulesetInfo!.CreateInstance(); + var beatmap = ruleset.CreateBeatmapConverter(new Beatmap()).Convert(); + var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap); + + ISkin provider = ruleset.CreateSkinTransformer(skins[skinName], beatmap)!; + + var gameplayState = TestGameplayState.Create(ruleset); + ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing; + var spectatorClient = new TestSpectatorClient(); + + for (int i = 0; i < 15; ++i) + { + ((ISpectatorClient)spectatorClient).UserStartedWatching([ + new SpectatorUser + { + OnlineID = i, + Username = $"User {i}" + } + ]); + } + + GameplayClockContainer gameplayClock; + + List<(Type, object)> dependencies = + [ + (typeof(GameplayState), gameplayState), + (typeof(ScoreProcessor), gameplayState.ScoreProcessor), + (typeof(HealthProcessor), gameplayState.HealthProcessor), + (typeof(IGameplayClock), gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false)), + (typeof(SpectatorClient), spectatorClient), + (typeof(IGameplayLeaderboardProvider), new TestGameplayLeaderboardProvider()), + ]; + + if (drawableRuleset is IDrawableScrollingRuleset scrolling) + dependencies.Add((typeof(IScrollingInfo), scrolling.ScrollingInfo)); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = dependencies.ToArray(), + Children = new Drawable[] + { + spectatorClient, + new SkinProvidingContainer(provider) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + drawableRuleset, + new HUDOverlay(drawableRuleset, []) + { + RelativeSizeAxes = Axes.Both, + } + } + } + } + }; + + gameplayClock.Start(); + }); + } + + private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider + { + IBindableList IGameplayLeaderboardProvider.Scores => Scores; + public BindableList Scores { get; } = new BindableList(); + + public TestGameplayLeaderboardProvider() + { + for (int i = 0; i < 20; ++i) + { + Scores.Add(new GameplayLeaderboardScore(new ScoreInfo + { + User = new APIUser { Username = $"User {i}" }, + TotalScore = (20 - i) * 50_000, + Accuracy = i * 0.05, + MaxCombo = i * 50, + }, i == 19, GameplayLeaderboardScore.ComboDisplayMode.Highest)); + } + } + } + + #region IResourceStorageProvider + + public IRenderer Renderer => host.Renderer; + public AudioManager AudioManager => Audio; + public IResourceStore Files => null!; + public new IResourceStore Resources => base.Resources; + public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); + RealmAccess IStorageResourceProvider.RealmAccess => null!; + + #endregion + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 2e646f2850..24215ed925 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -204,12 +204,7 @@ namespace osu.Game.Tests.Visual.Gameplay Origin = Anchor.Centre, Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Both, - Children = new[] - { - new OsuSpriteText { Text = $@"Great: {hitWindows?.WindowFor(HitResult.Great)}" }, - new OsuSpriteText { Text = $@"Good: {hitWindows?.WindowFor(HitResult.Ok)}" }, - new OsuSpriteText { Text = $@"Meh: {hitWindows?.WindowFor(HitResult.Meh)}" }, - } + ChildrenEnumerable = hitWindows?.GetAllAvailableWindows().Select(w => new OsuSpriteText { Text = $@"{w.result}: {w.length}" }) ?? [] }); Add(new BarHitErrorMeter @@ -289,7 +284,6 @@ namespace osu.Game.Tests.Visual.Gameplay public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } internal override bool FrameStablePlayback { get; set; } - public override bool AllowBackwardsSeeks { get; set; } public override IReadOnlyList Mods { get; } public override double GameplayStartTime { get; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs deleted file mode 100644 index ce93837925..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs +++ /dev/null @@ -1,24 +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.Shapes; -using osu.Game.Screens.Play.Break; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public partial class TestSceneLetterboxOverlay : OsuTestScene - { - public TestSceneLetterboxOverlay() - { - AddRange(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both - }, - new LetterboxOverlay() - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 6aa2c4e40d..08317c37cf 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; @@ -19,6 +20,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; +using osu.Game.Storyboards; using osuTK; using osuTK.Input; @@ -28,6 +30,12 @@ namespace osu.Game.Tests.Visual.Gameplay { protected new PausePlayer Player => (PausePlayer)base.Player; + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + { + beatmap.AudioLeadIn = 4000; + return base.CreateWorkingBeatmap(beatmap, storyboard); + } + private readonly Container content; protected override Container Content => content; @@ -61,6 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaBackAction(); pauseViaBackAction(); confirmPausedWithNoOverlay(); + AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Count.EqualTo(1)); } [Test] @@ -69,17 +78,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaPauseGameplayAction(); pauseViaPauseGameplayAction(); confirmPausedWithNoOverlay(); - } - - [Test] - public void TestForwardPlaybackGuarantee() - { - hookForwardPlaybackCheck(); - - AddUntilStep("wait for forward playback", () => Player.GameplayClockContainer.CurrentTime > 1000); - AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); - - checkForwardPlayback(); + AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Count.EqualTo(1)); } [Test] @@ -200,8 +199,10 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] + [Ignore("Fails on github runners if they happen to skip too far forward in time.")] public void TestUserPauseDuringCooldownTooSoon() { + AddStep("seek to gameplay", () => Player.GameplayClockContainer.Seek(0)); AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); pauseAndConfirm(); @@ -213,9 +214,23 @@ namespace osu.Game.Tests.Visual.Gameplay confirmNotExited(); } + [Test] + public void TestUserPauseDuringIntroSkipsCooldown() + { + AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); + AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); + + pauseAndConfirm(); + + resume(); + pauseViaBackAction(); + confirmPaused(); + } + [Test] public void TestQuickExitDuringCooldownTooSoon() { + AddStep("seek to gameplay", () => Player.GameplayClockContainer.Seek(0)); AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); pauseAndConfirm(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index c8b7ccc3d0..6ac82005a7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -19,6 +19,10 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; @@ -64,6 +68,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached(typeof(BatteryInfo))] private readonly LocalBatteryInfo batteryInfo = new LocalBatteryInfo(); + [Cached] + private readonly LeaderboardManager leaderboardManager; + private readonly ChangelogOverlay changelogOverlay; private double savedTrackVolume; @@ -74,6 +81,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddRange(new Drawable[] { + leaderboardManager = new LeaderboardManager(), notificationOverlay = new NotificationOverlay { Anchor = Anchor.TopRight, @@ -364,6 +372,45 @@ namespace osu.Game.Tests.Visual.Gameplay }, () => !volumeOverlay.IsMuted.Value && audioManager.Volume.Value == 0.5 && audioManager.VolumeTrack.Value == 0.5); } + [Test] + public void TestLeaderboardForciblyRefetchedOnRestart([Values] bool quickRestart) + { + int leaderboardRequestsHandled = 0; + AddStep("set up request handling", () => ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScores: + leaderboardRequestsHandled++; + getScores.TriggerSuccess(new APIScoresCollection { Scores = [] }); + return true; + + default: + return false; + } + }); + + AddStep("load player", () => resetPlayer(true)); + + AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen()); + AddUntilStep("wait for player to be current", () => player.IsCurrentScreen()); + AddAssert("leaderboard fetched once", () => leaderboardRequestsHandled, () => Is.EqualTo(1)); + + AddStep("restart player", () => + { + var lastPlayer = player; + player = null; + lastPlayer.Restart(quickRestart); + }); + + AddUntilStep("wait for player to be current", () => player.IsCurrentScreen()); + + if (quickRestart) + AddAssert("leaderboard not refetched", () => leaderboardRequestsHandled, () => Is.EqualTo(1)); + else + AddAssert("leaderboard fetched twice", () => leaderboardRequestsHandled, () => Is.EqualTo(2)); + } + /// /// Created for avoiding copy pasting code for the same steps. /// diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index 046ae6d953..0e6fd8f519 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -257,6 +257,14 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } + private class CustomRuleset : OsuRuleset, ILegacyRuleset { public override string Description => "custom"; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index c382f0828b..c0cddf0f6a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -16,6 +16,7 @@ using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -234,6 +235,31 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure no submission", () => Player.SubmittedScore == null); } + [Test] + public void TestNoSubmissionWhenScoreZero() + { + prepareTestAPI(true); + + createPlayerTest(); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + AddUntilStep("wait for first result", () => Player.Results.Count > 0); + + AddStep("add fake non-scoring hit", () => + { + Player.ScoreProcessor.RevertResult(Player.Results.First()); + Player.ScoreProcessor.ApplyResult(new OsuJudgementResult(Beatmap.Value.Beatmap.HitObjects.First(), new IgnoreJudgement()) + { + Type = HitResult.IgnoreHit, + }); + }); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + [Test] public void TestSubmissionOnExit() { @@ -290,6 +316,26 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure no submission", () => Player.SubmittedScore == null); } + [Test] + public void TestNoSubmissionOnLocallyModifiedBeatmapWithOnlineId() + { + prepareTestAPI(true); + + createPlayerTest(false, r => + { + var beatmap = createTestBeatmap(r); + beatmap.BeatmapInfo.Status = BeatmapOnlineStatus.LocallyModified; + return beatmap; + }); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + addFakeHit(); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + [TestCase(null)] [TestCase(10)] public void TestNoSubmissionOnCustomRuleset(int? rulesetId) diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index 88effb4a7b..b567e8de8d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -269,7 +269,6 @@ namespace osu.Game.Tests.Visual.Gameplay drawableRuleset = (TestDrawablePoolingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); drawableRuleset.FrameStablePlayback = true; - drawableRuleset.AllowBackwardsSeeks = true; drawableRuleset.PoolSize = poolSize; Child = new Container diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index 81dd23661c..6be8f7d185 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -6,11 +6,16 @@ using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Replays; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -19,6 +24,14 @@ namespace osu.Game.Tests.Visual.Gameplay { protected TestReplayPlayer Player = null!; + [Test] + public void TestFailedBeatmapLoad() + { + loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo, withHitObjects: false)); + + AddUntilStep("wait for exit", () => Player.IsCurrentScreen()); + } + [Test] public void TestPauseViaSpace() { @@ -157,6 +170,50 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("Jumped forwards", () => Player.GameplayClockContainer.CurrentTime - lastTime > 500); } + [Test] + public void TestReplayDoesNotFailUntilRunningOutOfFrames() + { + var score = new Score + { + ScoreInfo = TestResources.CreateTestScoreInfo(Beatmap.Value.BeatmapInfo), + Replay = new Replay + { + Frames = + { + new OsuReplayFrame(0, Vector2.Zero), + new OsuReplayFrame(10000, Vector2.Zero), + } + } + }; + score.ScoreInfo.Mods = []; + score.ScoreInfo.Rank = ScoreRank.F; + AddStep("set global state", () => + { + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + Ruleset.Value = Beatmap.Value.BeatmapInfo.Ruleset; + SelectedMods.Value = score.ScoreInfo.Mods; + }); + AddStep("create player", () => Player = new TestReplayPlayer(score, showResults: false)); + AddStep("load player", () => LoadScreen(Player)); + AddUntilStep("wait for loaded", () => Player.IsCurrentScreen()); + AddStep("seek to 8000", () => Player.Seek(8000)); + AddUntilStep("fail indicator visible", () => Player.ChildrenOfType().Any(indicator => indicator.IsAlive && indicator.IsPresent)); + } + + [Test] + public void TestPlayerLoaderSettingsHover() + { + loadPlayerWithBeatmap(); + + AddUntilStep("wait for settings overlay hidden", () => settingsOverlay().Expanded.Value, () => Is.False); + AddStep("move mouse to right of screen", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopRight)); + AddUntilStep("wait for settings overlay visible", () => settingsOverlay().Expanded.Value, () => Is.True); + AddStep("move mouse to centre of screen", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre)); + AddUntilStep("wait for settings overlay hidden", () => settingsOverlay().Expanded.Value, () => Is.False); + + PlayerSettingsOverlay settingsOverlay() => Player.ChildrenOfType().Single(); + } + private void loadPlayerWithBeatmap(IBeatmap? beatmap = null) { AddStep("create player", () => diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index a7ab021884..8ada550174 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -15,6 +15,7 @@ using osu.Framework.Input.Events; using osu.Framework.Input.StateChanges; using osu.Framework.Testing; using osu.Framework.Threading; +using osu.Framework.Timing; using osu.Game.Graphics.Sprites; using osu.Game.Replays; using osu.Game.Rulesets; @@ -42,6 +43,8 @@ namespace osu.Game.Tests.Visual.Gameplay private GameplayState gameplayState; + private Drawable content; + [SetUpSteps] public void SetUpSteps() { @@ -58,7 +61,7 @@ namespace osu.Game.Tests.Visual.Gameplay { RelativeSizeAxes = Axes.Both, CachedDependencies = new (Type, object)[] { (typeof(GameplayState), gameplayState) }, - Child = createContent(), + Child = content = createContent(), }; }); } @@ -67,10 +70,32 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestBasic() { AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("at least one frame recorded", () => replay.Frames.Count > 0); + AddUntilStep("at least one frame recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(0)); AddUntilStep("position matches", () => playbackManager.ChildrenOfType().First().Position == recordingManager.ChildrenOfType().First().Position); } + [Test] + [Explicit("Making this test work in a headless context is high effort due to rate adjustment requirements not aligning with the global fast clock. StopwatchClock usage would need to be replace with a rate adjusting clock that still reads from the parent clock. High effort for a test which likely will not see any changes to covered code for some years.")] + public void TestSlowClockStillRecordsFramesInRealtime() + { + ScheduledDelegate moveFunction = null; + + AddStep("set slow running clock", () => + { + var stopwatchClock = new StopwatchClock(true) { Rate = 0.01 }; + stopwatchClock.Seek(Clock.CurrentTime); + + content.Clock = new FramedClock(stopwatchClock); + }); + + AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); + AddStep("much move", () => moveFunction = Scheduler.AddDelayed(() => + InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); + AddWaitStep("move", 10); + AddStep("stop move", () => moveFunction.Cancel()); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(60)); + } + [Test] public void TestHighFrameRate() { @@ -81,7 +106,7 @@ namespace osu.Game.Tests.Visual.Gameplay InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); AddWaitStep("move", 10); AddStep("stop move", () => moveFunction.Cancel()); - AddAssert("at least 60 frames recorded", () => replay.Frames.Count > 60); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(60)); } [Test] @@ -97,7 +122,7 @@ namespace osu.Game.Tests.Visual.Gameplay InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); AddWaitStep("move", 10); AddStep("stop move", () => moveFunction.Cancel()); - AddAssert("less than 10 frames recorded", () => replay.Frames.Count - initialFrameCount < 10); + AddAssert("less than 10 frames recorded", () => replay.Frames.Count - initialFrameCount, () => Is.LessThan(10)); } [Test] @@ -114,7 +139,7 @@ namespace osu.Game.Tests.Visual.Gameplay }, 10, true)); AddWaitStep("move", 10); AddStep("stop move", () => moveFunction.Cancel()); - AddAssert("at least 60 frames recorded", () => replay.Frames.Count > 60); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count, () => Is.GreaterThanOrEqualTo(60)); } protected override void Update() @@ -292,6 +317,9 @@ namespace osu.Game.Tests.Visual.Gameplay Position = position; Actions.AddRange(actions); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is TestReplayFrame testFrame && Time == testFrame.Time && Position == testFrame.Position && Actions.SequenceEqual(testFrame.Actions); } public enum TestAction diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 91188f5bac..97889eea4d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Settings; using osu.Game.Overlays.SkinEditor; @@ -458,6 +459,62 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); } + [Test] + public void TestAnchorRadioButtonBehavior() + { + ISerialisableDrawable? selectedComponent = null; + + AddStep("Select first component", () => + { + var blueprint = skinEditor.ChildrenOfType().First(); + skinEditor.SelectedComponents.Clear(); + skinEditor.SelectedComponents.Add(blueprint.Item); + selectedComponent = blueprint.Item; + }); + + AddStep("Right-click to open context menu", () => + { + if (selectedComponent != null) + InputManager.MoveMouseTo(((Drawable)selectedComponent).ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Right); + }); + + AddStep("Click on Anchor menu", () => + { + InputManager.MoveMouseTo(getMenuItemByText("Anchor")); + InputManager.Click(MouseButton.Left); + }); + + AddStep("Right-click TopLeft anchor", () => + { + InputManager.MoveMouseTo(getMenuItemByText("TopLeft")); + InputManager.Click(MouseButton.Right); + }); + + AddAssert("TopLeft item checked", () => (getMenuItemByText("TopLeft").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True); + + AddStep("Right-click Centre anchor", () => + { + InputManager.MoveMouseTo(getMenuItemByText("Centre")); + InputManager.Click(MouseButton.Right); + }); + + AddAssert("Centre item checked", () => (getMenuItemByText("Centre").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True); + AddAssert("TopLeft item unchecked", () => (getMenuItemByText("TopLeft").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.False); + + AddStep("Right-click Closest anchor", () => + { + InputManager.MoveMouseTo(getMenuItemByText("Closest")); + InputManager.Click(MouseButton.Right); + }); + + AddAssert("Closest item checked", () => (getMenuItemByText("Closest").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True); + AddAssert("Centre item unchecked", () => (getMenuItemByText("Centre").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.False); + + Menu.DrawableMenuItem getMenuItemByText(string text) + => this.ChildrenOfType().First(m => m.Item.Text.ToString() == text); + } + private Skin importSkinFromArchives(string filename) { var imported = skins.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index 656873e9ed..00369ade18 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Edit; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Tests.Gameplay; using osuTK.Input; @@ -37,6 +38,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] public readonly EditorClipboard Clipboard = new EditorClipboard(); + [Cached(typeof(IGameplayLeaderboardProvider))] + private EmptyGameplayLeaderboardProvider leaderboardProvider = new EmptyGameplayLeaderboardProvider(); + public TestSceneSkinEditorMultipleSkins() { scoreProcessor = gameplayState.ScoreProcessor; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index fcaa2996e1..754ec841d8 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; using osu.Game.Tests.Gameplay; @@ -42,6 +43,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached(typeof(IGameplayClock))] private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false); + [Cached(typeof(IGameplayLeaderboardProvider))] + private EmptyGameplayLeaderboardProvider leaderboardProvider = new EmptyGameplayLeaderboardProvider(); + private IEnumerable hudOverlays => CreatedDrawables.OfType(); // best way to check without exposing. diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs index 098f8e3246..8e9df5b2bf 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs @@ -35,7 +35,9 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); - protected override Drawable CreateDefaultImplementation() => new ArgonKeyCounterDisplay(); + protected override Drawable CreateArgonImplementation() => new ArgonKeyCounterDisplay(); + + protected override Drawable CreateDefaultImplementation() => new DefaultKeyCounterDisplay(); protected override Drawable CreateLegacyImplementation() => new LegacyKeyCounterDisplay(); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index 8b1a8307ca..946b625608 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -69,6 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] + [FlakyTest] public void TestFadeOnIdle() { createTest(); @@ -144,7 +145,8 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestDoesntFadeOnMouseDown() + [FlakyTest] + public void TestDoesNotFadeOnMouseDown() { createTest(); @@ -171,7 +173,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Drawable OverlayContent => InternalChild; - public Drawable FadingContent => (OverlayContent as Container)?.Child; + public new Drawable FadingContent => (OverlayContent as Container)?.Child; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs deleted file mode 100644 index dbd14db818..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Testing; -using osu.Framework.Utils; -using osu.Game.Configuration; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select; -using osu.Game.Tests.Gameplay; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public partial class TestSceneSoloGameplayLeaderboard : OsuTestScene - { - [Cached(typeof(ScoreProcessor))] - private readonly ScoreProcessor scoreProcessor = TestGameplayState.Create(new OsuRuleset()).ScoreProcessor; - - private readonly BindableList scores = new BindableList(); - - private readonly Bindable configVisibility = new Bindable(); - private readonly Bindable beatmapTabType = new Bindable(); - - private SoloGameplayLeaderboard leaderboard = null!; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); - config.BindWith(OsuSetting.BeatmapDetailTab, beatmapTabType); - } - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("clear scores", () => scores.Clear()); - - AddStep("create component", () => - { - var trackingUser = new APIUser - { - Username = "local user", - Id = 2, - }; - - Child = leaderboard = new SoloGameplayLeaderboard(trackingUser) - { - Scores = { BindTarget = scores }, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AlwaysVisible = { Value = false }, - Expanded = { Value = true }, - }; - }); - - AddStep("add scores", () => scores.AddRange(createSampleScores())); - } - - [Test] - public void TestLocalUser() - { - AddSliderStep("score", 0, 1000000, 500000, v => scoreProcessor.TotalScore.Value = v); - AddSliderStep("accuracy", 0f, 1f, 0.5f, v => scoreProcessor.Accuracy.Value = v); - AddSliderStep("combo", 0, 10000, 0, v => scoreProcessor.HighestCombo.Value = v); - AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); - } - - [TestCase(PlayBeatmapDetailArea.TabType.Local, 51)] - [TestCase(PlayBeatmapDetailArea.TabType.Global, null)] - [TestCase(PlayBeatmapDetailArea.TabType.Country, null)] - [TestCase(PlayBeatmapDetailArea.TabType.Friends, null)] - public void TestTrackedScorePosition(PlayBeatmapDetailArea.TabType tabType, int? expectedOverflowIndex) - { - AddStep($"change TabType to {tabType}", () => beatmapTabType.Value = tabType); - AddUntilStep("tracked player is #50", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(50)); - - AddStep("add one more score", () => scores.Add(new ScoreInfo { User = new APIUser { Username = "New player 1" }, TotalScore = RNG.Next(600000, 1000000) })); - - AddUntilStep("wait for sort", () => leaderboard.ChildrenOfType().First().ScorePosition != null); - - if (expectedOverflowIndex == null) - AddUntilStep("tracked player has null position", () => leaderboard.TrackedScore?.ScorePosition, () => Is.Null); - else - AddUntilStep($"tracked player is #{expectedOverflowIndex}", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(expectedOverflowIndex)); - } - - [Test] - public void TestVisibility() - { - AddStep("set config visible true", () => configVisibility.Value = true); - AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1); - - AddStep("set config visible false", () => configVisibility.Value = false); - AddUntilStep("leaderboard not visible", () => leaderboard.Alpha == 0); - - AddStep("set always visible", () => leaderboard.AlwaysVisible.Value = true); - AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1); - - AddStep("set config visible true", () => configVisibility.Value = true); - AddAssert("leaderboard still visible", () => leaderboard.Alpha == 1); - } - - private static List createSampleScores() - { - return new[] - { - new ScoreInfo { User = new APIUser { Username = @"peppy" }, TotalScore = RNG.Next(500000, 1000000) }, - new ScoreInfo { User = new APIUser { Username = @"smoogipoo" }, TotalScore = RNG.Next(500000, 1000000) }, - new ScoreInfo { User = new APIUser { Username = @"spaceman_atlas" }, TotalScore = RNG.Next(500000, 1000000) }, - new ScoreInfo { User = new APIUser { Username = @"frenzibyte" }, TotalScore = RNG.Next(500000, 1000000) }, - new ScoreInfo { User = new APIUser { Username = @"Susko3" }, TotalScore = RNG.Next(500000, 1000000) }, - }.Concat(Enumerable.Range(0, 44).Select(i => new ScoreInfo { User = new APIUser { Username = $"User {i + 1}" }, TotalScore = 1000000 - i * 10000 })).ToList(); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs new file mode 100644 index 0000000000..7e728a84ca --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Gameplay; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [HeadlessTest] + public partial class TestSceneSoloGameplayLeaderboardProvider : OsuTestScene + { + [Test] + public void TestLocalLeaderboardHasPositionsAutofilled() + { + SoloGameplayLeaderboardProvider provider = null!; + + var leaderboardManager = new LeaderboardManager(); + LoadComponent(leaderboardManager); + var gameplayState = TestGameplayState.Create(new OsuRuleset()); + + AddStep("fetch local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(Beatmap.Value.BeatmapInfo, Ruleset.Value, BeatmapLeaderboardScope.Local, null))); + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success( + Enumerable.Range(1, 100).Select(i => new ScoreInfo + { + TotalScore = 10_000 * (100 - i), + Position = i, + }).ToArray(), + scoresRequested: 100, + totalScores: 100, + null + ); + }); + AddStep("create content", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LeaderboardManager), leaderboardManager), + (typeof(GameplayState), gameplayState) + ], + Children = new Drawable[] + { + leaderboardManager, + provider = new SoloGameplayLeaderboardProvider() + } + }); + AddUntilStep("tracked score shows #101", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(101)); + AddUntilStep("tracked score ordered #101", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(101)); + AddStep("move score to #20", () => gameplayState.ScoreProcessor.TotalScore.Value = 802_000); + AddUntilStep("tracked score shows #20", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(20)); + AddUntilStep("tracked score ordered #20", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(20)); + AddStep("move score to #1", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_002_000); + AddUntilStep("tracked score shows #1", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(1)); + AddUntilStep("tracked score ordered #1", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(1)); + } + + [Test] + public void TestFullGlobalLeaderboard() + { + SoloGameplayLeaderboardProvider provider = null!; + + var leaderboardManager = new LeaderboardManager(); + LoadComponent(leaderboardManager); + var gameplayState = TestGameplayState.Create(new OsuRuleset()); + + AddStep("fetch local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(Beatmap.Value.BeatmapInfo, Ruleset.Value, BeatmapLeaderboardScope.Global, null))); + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success( + Enumerable.Range(1, 40).Select(i => new ScoreInfo + { + TotalScore = 600_000 + 10_000 * (40 - i), + Position = i, + }).ToArray(), + scoresRequested: 50, + totalScores: 40, + null + ); + }); + AddStep("create content", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LeaderboardManager), leaderboardManager), + (typeof(GameplayState), gameplayState) + ], + Children = new Drawable[] + { + leaderboardManager, + provider = new SoloGameplayLeaderboardProvider() + } + }); + AddUntilStep("tracked score shows #41", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(41)); + AddUntilStep("tracked score ordered #41", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(41)); + AddStep("move score to #20", () => gameplayState.ScoreProcessor.TotalScore.Value = 802_000); + AddUntilStep("tracked score shows #20", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(20)); + AddUntilStep("tracked score ordered #20", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(20)); + AddStep("move score to #1", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_002_000); + AddUntilStep("tracked score shows #1", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(1)); + AddUntilStep("tracked score ordered #1", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(1)); + } + + [Test] + public void TestPartialGlobalLeaderboard() + { + SoloGameplayLeaderboardProvider provider = null!; + + var leaderboardManager = new LeaderboardManager(); + LoadComponent(leaderboardManager); + var gameplayState = TestGameplayState.Create(new OsuRuleset()); + + AddStep("fetch local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(Beatmap.Value.BeatmapInfo, Ruleset.Value, BeatmapLeaderboardScope.Global, null))); + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success( + Enumerable.Range(1, 50).Select(i => new ScoreInfo + { + TotalScore = 500_000 + 10_000 * (50 - i), + Position = i + }).ToArray(), + scoresRequested: 50, + totalScores: 1337, + new ScoreInfo { TotalScore = 200_000 } + ); + }); + AddStep("create content", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LeaderboardManager), leaderboardManager), + (typeof(GameplayState), gameplayState) + ], + Children = new Drawable[] + { + leaderboardManager, + provider = new SoloGameplayLeaderboardProvider() + } + }); + AddUntilStep("tracked score shows no position", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.Null); + AddUntilStep("tracked score ordered #52", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(52)); + AddStep("move score above user best", () => gameplayState.ScoreProcessor.TotalScore.Value = 202_000); + AddUntilStep("tracked score shows no position", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.Null); + AddUntilStep("tracked score ordered #51", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(51)); + AddStep("move score to #20", () => gameplayState.ScoreProcessor.TotalScore.Value = 802_000); + AddUntilStep("tracked score shows #20", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(20)); + AddUntilStep("tracked score ordered #20", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(20)); + AddStep("move score to #1", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_002_000); + AddUntilStep("tracked score shows #1", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(1)); + AddUntilStep("tracked score ordered #1", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(1)); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs new file mode 100644 index 0000000000..1445e872b5 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Visual.Spectator; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public partial class TestSceneSpectatorList : OsuTestScene + { + private int counter; + + [Test] + public void TestBasics() + { + SpectatorList list = null!; + Bindable playingState = new Bindable(); + GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), healthProcessor: new OsuHealthProcessor(0), localUserPlayingState: playingState); + TestSpectatorClient spectatorClient = new TestSpectatorClient(); + TestMultiplayerClient multiplayerClient = new TestMultiplayerClient(new TestRoomRequestsHandler()); + + AddStep("create spectator list", () => + { + Children = new Drawable[] + { + spectatorClient, + multiplayerClient, + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(GameplayState), gameplayState), + (typeof(SpectatorClient), spectatorClient), + (typeof(MultiplayerClient), multiplayerClient), + ], + Child = list = new SpectatorList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + }); + + AddStep("start playing", () => playingState.Value = LocalUserPlayingState.Playing); + + AddRepeatStep("add a user", () => + { + int id = Interlocked.Increment(ref counter); + ((ISpectatorClient)spectatorClient).UserStartedWatching([ + new SpectatorUser + { + OnlineID = id, + Username = $"User {id}" + } + ]); + }, 10); + + AddRepeatStep("remove random user", () => ((ISpectatorClient)spectatorClient).UserEndedWatching( + spectatorClient.WatchingUsers[RNG.Next(spectatorClient.WatchingUsers.Count)].OnlineID), 5); + + AddStep("change font to venera", () => list.HeaderFont.Value = Typeface.Venera); + AddStep("change font to torus", () => list.HeaderFont.Value = Typeface.Torus); + AddStep("change header colour", () => list.HeaderColour.Value = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1)); + + AddStep("enter break", () => playingState.Value = LocalUserPlayingState.Break); + AddStep("stop playing", () => playingState.Value = LocalUserPlayingState.NotPlaying); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index dd5bbf70b4..062d73abbf 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -353,6 +353,9 @@ namespace osu.Game.Tests.Visual.Gameplay return new LegacyReplayFrame(Time, Position.X, Position.Y, state); } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is TestReplayFrame testFrame && Time == testFrame.Time && Position == testFrame.Position && Actions.SequenceEqual(testFrame.Actions); } public enum TestAction diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index 5da60966b2..4b90bec784 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs @@ -2,12 +2,14 @@ // 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.Graphics.Shapes; +using osu.Framework.Testing; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -48,7 +50,7 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestVideoSize() + public void TestVideo() { AddStep("load storyboard with only video", () => { @@ -56,6 +58,7 @@ namespace osu.Game.Tests.Visual.Gameplay loadStoryboard("storyboard_only_video.osu", s => s.Beatmap.WidescreenStoryboard = false); }); + AddAssert("storyboard video present in hierarchy", () => this.ChildrenOfType().Any()); AddAssert("storyboard is correct width", () => Precision.AlmostEquals(storyboard?.Width ?? 0f, 480 * 16 / 9f)); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs index 73ec6ea335..3c48470bbe 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs @@ -4,8 +4,10 @@ #nullable disable using System; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Rulesets.Judgements; @@ -14,43 +16,50 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; -using osuTK; +using osu.Game.Skinning.Triangles; namespace osu.Game.Tests.Visual.Gameplay { - public partial class TestSceneUnstableRateCounter : OsuTestScene + public partial class TestSceneUnstableRateCounter : SkinnableHUDComponentTestScene { [Cached(typeof(ScoreProcessor))] private TestScoreProcessor scoreProcessor = new TestScoreProcessor(); private readonly OsuHitWindows hitWindows; - private UnstableRateCounter counter; - private double prev; + protected override Drawable CreateDefaultImplementation() => new TrianglesUnstableRateCounter(); + protected override Drawable CreateArgonImplementation() => new ArgonUnstableRateCounter(); + protected override Drawable CreateLegacyImplementation() => Empty(); + public TestSceneUnstableRateCounter() { hitWindows = new OsuHitWindows(); hitWindows.SetDifficulty(5); } - [SetUpSteps] - public void SetUp() + public override void SetUpSteps() { AddStep("Reset Score Processor", () => scoreProcessor.Reset()); + base.SetUpSteps(); + } + + [Test] + public void TestDisplay() + { + AddSliderStep("UR", 0, 2000, 0, v => this.ChildrenOfType().ForEach(c => c.Current.Value = v)); + AddToggleStep("toggle validity", v => this.ChildrenOfType().ForEach(c => c.IsValid.Value = v)); } [Test] public void TestBasic() { - AddStep("Create Display", recreateDisplay); - // Needs multiples 2 by the nature of UR, and went for 4 to be safe. // Creates a 250 UR by placing a +25ms then a -25ms judgement, which then results in a 250 UR AddRepeatStep("Set UR to 250", () => applyJudgement(25, true), 4); - AddUntilStep("UR = 250", () => counter.Current.Value == 250.0); + AddUntilStep("UR = 250", () => this.ChildrenOfType().All(c => c.Current.Value == 250)); AddRepeatStep("Revert UR", () => { @@ -63,8 +72,8 @@ namespace osu.Game.Tests.Visual.Gameplay }); }, 4); - AddUntilStep("UR is 0", () => counter.Current.Value == 0.0); - AddUntilStep("Counter is invalid", () => counter.Child.Alpha == 0.3f); + AddUntilStep("UR is 0", () => this.ChildrenOfType().All(c => c.Current.Value == 0)); + AddUntilStep("Counter is invalid", () => this.ChildrenOfType().All(c => !c.IsValid.Value)); //Sets a UR of 0 by creating 10 10ms offset judgements. Since average = offset, UR = 0 AddRepeatStep("Set UR to 0", () => applyJudgement(10, false), 10); @@ -77,42 +86,24 @@ namespace osu.Game.Tests.Visual.Gameplay { AddRepeatStep("Set UR to 250", () => applyJudgement(25, true), 4); - AddStep("Create Display", recreateDisplay); - - AddUntilStep("UR = 250", () => counter.Current.Value == 250.0); + AddUntilStep("UR = 250", () => this.ChildrenOfType().All(c => c.Current.Value == 250)); } [Test] public void TestStaticRateChange() { - AddStep("Create Display", recreateDisplay); - AddRepeatStep("Set UR to 250 at 1.5x", () => applyJudgement(25, true, 1.5), 4); - AddUntilStep("UR = 250/1.5", () => counter.Current.Value == Math.Round(250.0 / 1.5)); + AddUntilStep("UR = 250/1.5", () => this.ChildrenOfType().All(c => c.Current.Value == (int)Math.Round(250.0 / 1.5))); } [Test] public void TestDynamicRateChange() { - AddStep("Create Display", recreateDisplay); - AddRepeatStep("Set UR to 100 at 1.0x", () => applyJudgement(10, true, 1.0), 4); AddRepeatStep("Bring UR to 100 at 1.5x", () => applyJudgement(15, true, 1.5), 4); - AddUntilStep("UR = 100", () => counter.Current.Value == 100.0); - } - - private void recreateDisplay() - { - Clear(); - - Add(counter = new UnstableRateCounter - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(5), - }); + AddUntilStep("UR = 100", () => this.ChildrenOfType().All(c => c.Current.Value == 100)); } private void applyJudgement(double offsetMs, bool alt, double gameplayRate = 1.0) diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectGrid.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectGrid.cs new file mode 100644 index 0000000000..0e5e5b8aae --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectGrid.cs @@ -0,0 +1,250 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; +using osu.Game.Tests.Visual.OnlinePlay; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneBeatmapSelectGrid : OnlinePlayTestScene + { + private MatchmakingPlaylistItem[] items = null!; + + private BeatmapSelectGrid grid = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + var beatmaps = beatmapManager.GetAllUsableBeatmapSets() + .SelectMany(it => it.Beatmaps) + .Take(50) + .ToArray(); + + IEnumerable playlistItems; + + if (beatmaps.Length > 0) + { + playlistItems = Enumerable.Range(1, 50).Select(i => + { + var beatmap = beatmaps[i % beatmaps.Length]; + + return new MatchmakingPlaylistItem( + new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = beatmap.OnlineID, + StarRating = i / 10.0, + }, + CreateAPIBeatmap(beatmap), + Array.Empty() + ); + }); + } + else + { + playlistItems = Enumerable.Range(1, 50).Select(i => new MatchmakingPlaylistItem( + new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + }, + CreateAPIBeatmap(), + Array.Empty() + )); + } + + foreach (var item in playlistItems) + item.Beatmap.StarRating = item.PlaylistItem.StarRating; + + items = playlistItems.ToArray(); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add grid", () => Child = grid = new BeatmapSelectGrid + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.8f), + }); + + AddStep("add items", () => + { + grid.AddItems(items); + }); + + AddWaitStep("wait for panels", 3); + } + + [Test] + public void TestBasic() + { + AddStep("do nothing", () => + { + // test scene is weird. + }); + + AddStep("add selection 1", () => grid.ChildrenOfType().First().AddUser(new APIUser + { + Id = DummyAPIAccess.DUMMY_USER_ID, + Username = "Maarvin", + })); + AddStep("add selection 2", () => grid.ChildrenOfType().Skip(5).First().AddUser(new APIUser + { + Id = 2, + Username = "peppy", + })); + AddStep("add selection 3", () => grid.ChildrenOfType().Skip(10).First().AddUser(new APIUser + { + Id = 1040328, + Username = "smoogipoo", + })); + } + + [Test] + public void TestCompleteRollAnimation() + { + AddStep("play animation", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.RollAndDisplayFinalBeatmap(candidateItems, finalItem); + }); + } + + [Test] + public void TestRollAnimation() + { + AddStep("play animation", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + + Scheduler.AddDelayed(() => grid.PlayRollAnimation(finalItem), 500); + }); + } + + [Test] + public void TestPresentRolledBeatmap() + { + AddStep("present beatmap", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + grid.PlayRollAnimation(finalItem, duration: 0); + + Scheduler.AddDelayed(() => grid.PresentRolledBeatmap(finalItem), 500); + }); + } + + [Test] + public void TestPresentUnanimouslyChosenBeatmap() + { + AddStep("present beatmap", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + grid.PlayRollAnimation(finalItem, duration: 0); + + Scheduler.AddDelayed(() => grid.PresentUnanimouslyChosenBeatmap(finalItem), 500); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + public void TestPanelArrangement(int count) + { + AddStep("arrange panels", () => + { + var (candidateItems, _) = pickRandomItems(count); + + grid.TransferCandidatePanelsToRollContainer(candidateItems); + grid.Delay(BeatmapSelectGrid.ARRANGE_DELAY) + .Schedule(() => grid.ArrangeItemsForRollAnimation()); + }); + + AddWaitStep("wait for movement", 5); + + AddStep("display roll order", () => + { + var panels = grid.ChildrenOfType().ToArray(); + + for (int i = 0; i < panels.Length; i++) + { + var panel = panels[i]; + + panel.Add(new OsuSpriteText + { + Text = (i + 1).ToString(), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 50, weight: FontWeight.SemiBold), + }); + } + }); + } + + [Test] + public void TestPresentRandomItem() + { + AddStep("present random item panel", () => + { + grid.TransferCandidatePanelsToRollContainer(pickRandomItems(4).candidateItems.Append(-1).ToArray(), duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + grid.PlayRollAnimation(-1, duration: 0); + + Scheduler.AddDelayed(() => grid.PresentUnanimouslyChosenBeatmap(-1), 500); + }); + + AddWaitStep("wait for animation", 5); + + AddStep("reveal beatmap", () => grid.RevealRandomItem(items[RNG.Next(items.Length)].PlaylistItem)); + } + + private (long[] candidateItems, long finalItem) pickRandomItems(int count) + { + long[] candidateItems = items.Select(it => it.ID).ToArray(); + Random.Shared.Shuffle(candidateItems); + candidateItems = candidateItems.Take(count).ToArray(); + + long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)]; + + return (candidateItems, finalItem); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs new file mode 100644 index 0000000000..9ac64288ed --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics.Cursor; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneBeatmapSelectPanel : MultiplayerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(MatchType.Matchmaking); + room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = 0, + StarRating = i / 10.0, + })).ToArray(); + + JoinRoom(room); + }); + } + + [Test] + public void TestBeatmapPanel() + { + MatchmakingSelectPanel? panel = null; + + AddStep("add panel", () => + { + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = panel = new MatchmakingSelectPanelBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), CreateAPIBeatmap(), [])) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + }); + + AddStep("add maarvin", () => panel!.AddUser(new APIUser + { + Id = DummyAPIAccess.DUMMY_USER_ID, + Username = "Maarvin", + })); + AddStep("add peppy", () => panel!.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + })); + AddStep("add smogipoo", () => panel!.AddUser(new APIUser + { + Id = 1040328, + Username = "smoogipoo", + })); + AddStep("remove smogipoo", () => panel!.RemoveUser(new APIUser { Id = 1040328 })); + AddStep("remove peppy", () => panel!.RemoveUser(new APIUser { Id = 2 })); + AddStep("remove maarvin", () => panel!.RemoveUser(new APIUser { Id = 6411631 })); + + AddToggleStep("allow selection", value => panel!.AllowSelection = value); + } + + [Test] + public void TestRandomPanel() + { + MatchmakingSelectPanelRandom? panel = null; + + AddStep("add panel", () => + { + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = panel = new MatchmakingSelectPanelRandom(new MultiplayerPlaylistItem { ID = -1 }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + }); + + AddToggleStep("allow selection", value => panel!.AllowSelection = value); + + AddStep("reveal beatmap", () => panel!.RevealBeatmap(CreateAPIBeatmap(), [])); + } + + [Test] + public void TestBeatmapWithMods() + { + AddStep("add panel", () => + { + MatchmakingSelectPanel? panel; + + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = panel = new MatchmakingSelectPanelBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), CreateAPIBeatmap(), [new OsuModHardRock(), new OsuModDoubleTime()])) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + + panel.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + }); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingChatDisplay.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingChatDisplay.cs new file mode 100644 index 0000000000..d8e42cd946 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingChatDisplay.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingChatDisplay : ScreenTestScene + { + private MatchmakingChatDisplay? chat; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add chat", () => + { + chat?.Expire(); + + ScreenFooter.Add(chat = new MatchmakingChatDisplay(new Room()) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Size = new Vector2(700, 130), + Margin = new MarginPadding { Bottom = 10, Right = WaveOverlayContainer.WIDTH_PADDING - OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, + Alpha = 0 + }); + }); + + AddStep("show footer", () => ScreenFooter.Show()); + } + + [Test] + public void TestAppearDisappear() + { + AddStep("appear", () => chat!.Appear()); + AddWaitStep("wait for animation", 3); + + AddStep("disappear", () => chat!.Disappear()); + AddWaitStep("wait for animation", 3); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCloud.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCloud.cs new file mode 100644 index 0000000000..d656971b5a --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCloud.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.Graphics; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingCloud : OsuTestScene + { + private CloudVisualisation cloud = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Child = cloud = new CloudVisualisation + { + RelativeSizeAxes = Axes.Both, + }; + } + + [Test] + public void TestBasic() + { + AddStep("refresh users", () => + { + var testUsers = Enumerable.Range(0, 50).Select(_ => new APIUser + { + Username = "peppy", + Statistics = new UserStatistics { GlobalRank = 1234 }, + Id = RNG.Next(2, 30000000), + }).ToArray(); + + cloud.Users = testUsers; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingPoolSelector.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingPoolSelector.cs new file mode 100644 index 0000000000..c05614e9a4 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingPoolSelector.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Online.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingPoolSelector : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add selector", () => Child = new PoolSelector + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AvailablePools = + { + Value = + [ + new MatchmakingPool { Id = 0, RulesetId = 0, Name = "osu!" }, + new MatchmakingPool { Id = 1, RulesetId = 1, Name = "osu!taiko" }, + new MatchmakingPool { Id = 2, RulesetId = 2, Name = "osu!catch" }, + new MatchmakingPool { Id = 3, RulesetId = 3, Variant = 4, Name = "osu!mania (4k)" }, + new MatchmakingPool { Id = 4, RulesetId = 3, Variant = 7, Name = "osu!mania (7k)" }, + ] + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs new file mode 100644 index 0000000000..07d0fe6ed9 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking.Intro; +using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingQueueScreen : MultiplayerTestScene + { + [Cached] + private readonly QueueController controller = new QueueController(); + + private ScreenQueue? queueScreen => Stack.CurrentScreen as ScreenQueue; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("load screen", () => LoadScreen(new ScreenIntro())); + } + + [Test] + public void TestBasic() + { + AddUntilStep("wait for queue screen", () => queueScreen?.IsLoaded == true); + + AddStep("set users", () => + { + queueScreen!.Users = Enumerable.Range(0, 10).Select(_ => new APIUser + { + Username = "peppy", + Statistics = new UserStatistics { GlobalRank = 1234 }, + Id = RNG.Next(2, 30000000), + }).ToArray(); + }); + + AddStep("change state to idle", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Idle)); + + AddStep("change state to queueing", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Queueing)); + + AddStep("change state to found match", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.PendingAccept)); + + AddStep("change state to waiting for room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom)); + + AddStep("change state to in room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom)); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs new file mode 100644 index 0000000000..e88b10d30d --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs @@ -0,0 +1,200 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingScreen : MultiplayerTestScene + { + private const int user_count = 8; + private const int beatmap_count = 50; + + private MultiplayerRoomUser[] users = null!; + private ScreenMatchmaking screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(MatchType.Matchmaking); + room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + })).ToArray(); + + JoinRoom(room); + }); + + WaitForJoined(); + + AddStep("join users", () => + { + for (int i = 0; i < 7; i++) + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"User {i}" + } + }); + } + }); + + setupRequestHandler(); + + AddStep("load match", () => + { + users = Enumerable.Range(1, user_count).Select(i => new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"Player {i}" + } + }).ToArray(); + + var beatmaps = Enumerable.Range(1, beatmap_count).Select(i => new MultiplayerPlaylistItem + { + BeatmapID = i, + StarRating = i / 10.0 + }).ToArray(); + + LoadScreen(screen = new ScreenMatchmaking(new MultiplayerRoom(0) + { + Users = users, + Playlist = beatmaps + })); + }); + AddUntilStep("wait for load", () => screen.IsCurrentScreen()); + } + + [Test] + public void TestGameplayFlow() + { + for (int round = 1; round <= 3; round++) + { + AddLabel($"Round {round}"); + + int r = round; + changeStage(MatchmakingStage.RoundWarmupTime, state => state.CurrentRound = r); + changeStage(MatchmakingStage.UserBeatmapSelect); + changeStage(MatchmakingStage.ServerBeatmapFinalised, state => + { + MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 8).Select(i => new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + }).ToArray(); + + state.CandidateItems = beatmaps.Select(b => b.ID).ToArray(); + state.CandidateItem = beatmaps[0].ID; + }, waitTime: 35); + + changeStage(MatchmakingStage.WaitingForClientsBeatmapDownload); + changeStage(MatchmakingStage.GameplayWarmupTime); + changeStage(MatchmakingStage.Gameplay); + changeStage(MatchmakingStage.ResultsDisplaying); + } + + changeStage(MatchmakingStage.Ended, state => + { + int i = 1; + + foreach (var user in MultiplayerClient.ServerRoom!.Users.OrderBy(_ => RNG.Next())) + { + state.Users.GetOrAdd(user.UserID).Placement = i++; + state.Users.GetOrAdd(user.UserID).Points = (8 - i) * 7; + state.Users.GetOrAdd(user.UserID).Rounds.GetOrAdd(1).Placement = 1; + state.Users.GetOrAdd(user.UserID).Rounds.GetOrAdd(1).TotalScore = 1; + state.Users.GetOrAdd(user.UserID).Rounds.GetOrAdd(1).Statistics[HitResult.LargeBonus] = 1; + } + }); + } + + private void changeStage(MatchmakingStage stage, Action? prepare = null, int waitTime = 5) + { + AddStep($"stage: {stage}", () => MultiplayerClient.MatchmakingChangeStage(stage, prepare).WaitSafely()); + AddWaitStep("wait", waitTime); + } + + private void setupRequestHandler() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = null; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapsRequest getBeatmaps: + getBeatmaps.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap + { + OnlineID = id, + StarRating = id, + DifficultyName = $"Beatmap {id}", + BeatmapSet = new APIBeatmapSet + { + Title = $"Title {id}", + Artist = $"Artist {id}", + AuthorString = $"Author {id}" + } + }).ToList() + }); + return true; + + case IndexPlaylistScoresRequest index: + var result = new IndexedMultiplayerScores(); + + for (int i = 0; i < 8; ++i) + { + result.Scores.Add(new MultiplayerScore + { + ID = i, + Accuracy = 1 - (float)i / 16, + Position = i + 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH), + MaxCombo = 1000 - i, + TotalScore = (long)(1_000_000 * (1 - (float)i / 16)), + User = new APIUser { Username = $"user {i}" }, + Statistics = new Dictionary() + }); + } + + index.TriggerSuccess(result); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePanelRoomAward.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePanelRoomAward.cs new file mode 100644 index 0000000000..bdae656855 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePanelRoomAward.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.Graphics; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestScenePanelRoomAward : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add award", () => Child = new PanelRoomAward("Award name", "Description of what this award means", 1) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs new file mode 100644 index 0000000000..e894616f9e --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestScenePickScreen : MultiplayerTestScene + { + private readonly IReadOnlyList users = new[] + { + new APIUser + { + Id = 2, + Username = "peppy", + }, + new APIUser + { + Id = 1040328, + Username = "smoogipoo", + }, + new APIUser + { + Id = 6573093, + Username = "OliBomby", + }, + new APIUser + { + Id = 7782553, + Username = "aesth", + }, + new APIUser + { + Id = 6411631, + Username = "Maarvin", + } + }; + + private readonly PlaylistItem[] items = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + })).ToArray(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(MatchType.Matchmaking); + room.Playlist = items; + + JoinRoom(room); + }); + + WaitForJoined(); + + AddStep("add users", () => + { + foreach (var user in users) + MultiplayerClient.AddUser(user); + }); + } + + [Test] + public void TestScreen() + { + var selectedItems = new List(); + + SubScreenBeatmapSelect screen = null!; + + AddStep("add screen", () => Child = new ScreenStack(screen = new SubScreenBeatmapSelect())); + + AddStep("select maps", () => + { + selectedItems.Clear(); + + foreach (var user in users) + { + var item = items[Random.Shared.Next(items.Length)]; + selectedItems.Add(item.ID); + + Scheduler.AddDelayed(() => + { + MultiplayerClient.MatchmakingToggleUserSelection(user.Id, item.ID).FireAndForget(); + }, RNG.NextDouble(10, 1000)); + } + }); + + AddStep("show final map", () => + { + long[] candidateItems = selectedItems.ToArray(); + long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)]; + + screen.RollFinalBeatmap(candidateItems, finalItem); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs new file mode 100644 index 0000000000..0c78038179 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs @@ -0,0 +1,127 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking.Events; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestScenePlayerPanel : MultiplayerTestScene + { + private PlayerPanel panel = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking))); + WaitForJoined(); + + AddStep("join other player to room", () => MultiplayerClient.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + })); + + AddStep("add panel", () => Child = panel = new PlayerPanel(new MultiplayerRoomUser(2) + { + User = new APIUser + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/2/baba245ef60834b769694178f8f6d4f6166c5188c740de084656ad2b80f1eea7.jpeg", + Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } + } + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + [Test] + public void TestIncreasePlacement() + { + int rank = 0; + + AddStep("increase placement", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Users = + { + UserDictionary = + { + { + 2, new MatchmakingUser + { + UserId = 2, + Placement = ++rank + } + } + } + } + }).WaitSafely()); + + foreach (var layout in Enum.GetValues()) + { + AddStep($"set layout to {layout}", () => panel.DisplayMode = layout); + } + } + + [Test] + public void TestIncreasePoints() + { + int points = 0; + + AddStep("increase points", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Users = + { + UserDictionary = + { + { + 2, new MatchmakingUser + { + UserId = 2, + Placement = 1, + Points = ++points + } + } + } + } + }).WaitSafely()); + } + + [Test] + public void TestJump() + { + AddStep("jump", () => MultiplayerClient.SendUserMatchRequest(2, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely()); + } + + [Test] + public void TestQuit() + { + AddToggleStep("toggle quit", quit => panel.HasQuit = quit); + } + + [Test] + public void TestDownloadProgress() + { + AddStep("set download progress 20%", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.Downloading(0.2f))); + AddStep("set download progress 50%", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.Downloading(0.5f))); + AddStep("set download progress 90%", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.Downloading(0.9f))); + AddStep("set locally available", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.LocallyAvailable())); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs new file mode 100644 index 0000000000..cdc0c93d11 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs @@ -0,0 +1,223 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking.Events; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestScenePlayerPanelOverlay : MultiplayerTestScene + { + private PlayerPanelOverlay list = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking))); + WaitForJoined(); + + AddStep("add list", () => Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Child = list = new PlayerPanelOverlay() + }); + } + + [Test] + public void TestChangeDisplayMode() + { + AddStep("join users", () => + { + for (int i = 0; i < 7; i++) + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"User {i}" + } + }); + } + }); + + AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split); + AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid); + AddStep("change to hidden mode", () => list.DisplayStyle = PanelDisplayStyle.Hidden); + } + + [Test] + public void AddPanelsGrid() + { + AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid); + + int userId = 0; + + AddRepeatStep("join user", () => + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(userId) + { + User = new APIUser + { + Username = $"User {userId}" + } + }); + + userId++; + }, 8); + } + + [Test] + public void AddPanelsSplit() + { + AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split); + + int userId = 0; + + AddRepeatStep("join user", () => + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(userId) + { + User = new APIUser + { + Username = $"User {userId}" + } + }); + + userId++; + }, 8); + } + + [Test] + public void RemovePanels() + { + AddStep("join another user", () => + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(1) + { + User = new APIUser + { + Username = "User 1" + } + }); + }); + + AddUntilStep("two panels displayed", () => this.ChildrenOfType().Count(), () => Is.EqualTo(2)); + AddAssert("no panels quit", () => this.ChildrenOfType().Count(p => p.HasQuit), () => Is.EqualTo(0)); + + AddStep("remove a user", () => MultiplayerClient.RemoveUser(new APIUser { Id = 1 })); + + AddUntilStep("one panel quit", () => this.ChildrenOfType().Count(p => p.HasQuit), () => Is.EqualTo(1)); + AddAssert("two panels still displayed", () => this.ChildrenOfType().Count(), () => Is.EqualTo(2)); + } + + [Test] + public void ChangeRankings() + { + AddStep("join users", () => + { + for (int i = 0; i < 7; i++) + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"User {i}" + } + }); + } + }); + + AddStep("set random placements", () => + { + MultiplayerRoom room = MultiplayerClient.ServerRoom!; + + int[] placements = Enumerable.Range(1, room.Users.Count).ToArray(); + Random.Shared.Shuffle(placements); + + MatchmakingRoomState state = new MatchmakingRoomState(); + + for (int i = 0; i < room.Users.Count; i++) + state.Users.GetOrAdd(room.Users[i].UserID).Placement = placements[i]; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } + + [Test] + public void InteractionSpam() + { + AddStep("join users", () => + { + for (int i = 0; i < 7; i++) + { + MultiplayerClient.AddUser(new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"User {i}" + } + }); + } + }); + AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid); + AddStep("player jump", () => { MultiplayerClient.SendUserMatchRequest(1001, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely(); }); + AddStep("local jumping", () => jumpSpam(false)); + AddWaitStep("wait", 25); + AddStep("group jumping spam", () => jumpSpam(true)); + AddWaitStep("wait", 25); + + AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split); + AddStep("local jumping", () => jumpSpam(false)); + AddWaitStep("wait", 25); + AddStep("group jumping spam", () => jumpSpam(true)); + AddWaitStep("wait", 25); + + AddStep("change to hidden mode", () => list.DisplayStyle = PanelDisplayStyle.Hidden); + AddStep("local jumping", () => jumpSpam(false)); + AddWaitStep("wait", 25); + AddStep("group jumping spam", () => jumpSpam(true)); + AddWaitStep("wait", 25); + } + + private void jumpSpam(bool everyone) + { + for (int i = 0; i < 30; i++) + { + Scheduler.AddDelayed(() => + { + MultiplayerClient.SendUserMatchRequest(1001, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely(); + }, i * 150 + RNG.NextDouble(0, 140)); + + if (!everyone) + continue; + + for (int ii = 0; ii < 7; ii++) + { + int iii = ii; + Scheduler.AddDelayed(() => + { + MultiplayerClient.SendUserMatchRequest(iii, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely(); + }, i * 150 + RNG.NextDouble(0, 140)); + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs new file mode 100644 index 0000000000..843c20b1e5 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs @@ -0,0 +1,183 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneResultsScreen : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking))); + WaitForJoined(); + + AddStep("set initial results", () => + { + var state = new MatchmakingRoomState + { + CurrentRound = 6, + Stage = MatchmakingStage.Ended + }; + + int localUserId = API.LocalUser.Value.OnlineID; + + // Overall state. + state.Users.GetOrAdd(localUserId).Placement = 1; + state.Users.GetOrAdd(localUserId).Points = 8; + for (int round = 1; round <= state.CurrentRound; round++) + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(round).Placement = round; + + // Highest score. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(1).TotalScore = 1000; + + // Highest accuracy. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(2).Accuracy = 0.9995; + + // Highest combo. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(3).MaxCombo = 100; + + // Most bonus score. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(4).Statistics[HitResult.LargeBonus] = 50; + + // Smallest score difference. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(5).TotalScore = 1000; + + // Largest score difference. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(6).TotalScore = 1000; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + + AddStep("add results screen", () => + { + Child = new ScreenStack(new SubScreenResults()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.8f) + }; + }); + } + + [Test] + public void TestBasic() + { + AddStep("do nothing", () => { }); + } + + [Test] + public void TestInvalidUser() + { + const int invalid_user_id = 1; + AddStep("join another user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(invalid_user_id) + { + User = new APIUser + { + Id = invalid_user_id, + Username = "Invalid user" + } + })); + + AddStep("set results stage", () => + { + var state = new MatchmakingRoomState + { + CurrentRound = 6, + Stage = MatchmakingStage.Ended + }; + + int localUserId = API.LocalUser.Value.OnlineID; + + // Overall state. + state.Users.GetOrAdd(localUserId).Placement = 1; + state.Users.GetOrAdd(localUserId).Points = 8; + state.Users.GetOrAdd(invalid_user_id).Placement = 2; + state.Users.GetOrAdd(invalid_user_id).Points = 7; + for (int round = 1; round <= state.CurrentRound; round++) + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(round).Placement = round; + + // Highest score. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(1).TotalScore = 1000; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(1).TotalScore = 990; + + // Highest accuracy. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(2).Accuracy = 0.9995; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(2).Accuracy = 0.5; + + // Highest combo. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(3).MaxCombo = 100; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(3).MaxCombo = 10; + + // Most bonus score. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(4).Statistics[HitResult.LargeBonus] = 50; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(4).Statistics[HitResult.LargeBonus] = 25; + + // Smallest score difference. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(5).TotalScore = 1000; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(5).TotalScore = 999; + + // Largest score difference. + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(6).TotalScore = 1000; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(6).TotalScore = 0; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } + + [Test] + public void TestNoUsers() + { + AddStep("show results with no users", () => + { + var state = new MatchmakingRoomState + { + CurrentRound = 6, + Stage = MatchmakingStage.Ended + }; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } + + [Test] + public void TestUserWithNoScore() + { + AddStep("join another user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(2) + { + User = new APIUser + { + Id = 2, + Username = "Other user" + } + })); + + AddStep("show results with no score", () => + { + var state = new MatchmakingRoomState + { + CurrentRound = 6, + Stage = MatchmakingStage.Ended + }; + + state.Users.GetOrAdd(API.LocalUser.Value.OnlineID).Rounds.GetOrAdd(1).Placement = 1; + state.Users.GetOrAdd(2); + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs new file mode 100644 index 0000000000..cbdbd33158 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneRoundResultsScreen : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking))); + WaitForJoined(); + + setupRequestHandler(); + + AddStep("load screen", () => + { + Child = new ScreenStack(new SubScreenRoundResults()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.8f) + }; + }); + } + + private void setupRequestHandler() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = null; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapsRequest getBeatmaps: + getBeatmaps.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap + { + OnlineID = id, + StarRating = id, + DifficultyName = $"Beatmap {id}", + BeatmapSet = new APIBeatmapSet + { + Title = $"Title {id}", + Artist = $"Artist {id}", + AuthorString = $"Author {id}" + } + }).ToList() + }); + return true; + + case IndexPlaylistScoresRequest index: + var result = new IndexedMultiplayerScores(); + + for (int i = 0; i < 8; ++i) + { + result.Scores.Add(new MultiplayerScore + { + ID = i, + Accuracy = 1 - (float)i / 16, + Position = i + 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH), + MaxCombo = 1000 - i, + TotalScore = (long)(1_000_000 * (1 - (float)i / 16)), + User = new APIUser { Username = $"user {i}" }, + Statistics = new Dictionary() + }); + } + + index.TriggerSuccess(result); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs new file mode 100644 index 0000000000..dc4f09c555 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneStageDisplay : MultiplayerTestScene + { + [Cached] + protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking))); + WaitForJoined(); + + AddStep("add display", () => Child = new StageDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + }); + } + + [Test] + public void TestChangeStage() + { + addStage(MatchmakingStage.WaitingForClientsJoin); + + for (int i = 1; i <= 5; i++) + { + addStage(MatchmakingStage.RoundWarmupTime); + addStage(MatchmakingStage.UserBeatmapSelect); + addStage(MatchmakingStage.ServerBeatmapFinalised); + addStage(MatchmakingStage.WaitingForClientsBeatmapDownload); + addStage(MatchmakingStage.GameplayWarmupTime); + addStage(MatchmakingStage.Gameplay); + addStage(MatchmakingStage.ResultsDisplaying); + } + + addStage(MatchmakingStage.Ended); + } + + private void addStage(MatchmakingStage stage) + { + AddStep($"{stage}", () => MultiplayerClient.MatchmakingChangeStage(stage).WaitSafely()); + AddWaitStep("wait a bit", 10); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs index b09dbc1a91..2b0717c1e3 100644 --- a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Menus protected OsuScreenStack IntroStack; - private IntroScreen intro; + protected IntroScreen Intro { get; private set; } [Cached(typeof(INotificationOverlay))] private NotificationOverlay notifications; @@ -62,22 +62,9 @@ namespace osu.Game.Tests.Visual.Menus [Test] public virtual void TestPlayIntro() { - AddStep("restart sequence", () => - { - logo.FinishTransforms(); - logo.IsTracking = false; + RestartIntro(); - IntroStack?.Expire(); - - Add(IntroStack = new OsuScreenStack - { - RelativeSizeAxes = Axes.Both, - }); - - IntroStack.Push(intro = CreateScreen()); - }); - - AddUntilStep("wait for menu", () => intro.DidLoadMenu); + WaitForMenu(); } [Test] @@ -103,18 +90,18 @@ namespace osu.Game.Tests.Visual.Menus RelativeSizeAxes = Axes.Both, }); - IntroStack.Push(intro = CreateScreen()); + IntroStack.Push(Intro = CreateScreen()); }); AddStep("trigger failure", () => { trackResetDelegate = Scheduler.AddDelayed(() => { - intro.Beatmap.Value.Track.Seek(0); + Intro.Beatmap.Value.Track.Seek(0); }, 0, true); }); - AddUntilStep("wait for menu", () => intro.DidLoadMenu); + WaitForMenu(); if (IntroReliesOnTrack) AddUntilStep("wait for notification", () => notifications.UnreadCount.Value == 1); @@ -122,6 +109,29 @@ namespace osu.Game.Tests.Visual.Menus AddStep("uninstall delegate", () => trackResetDelegate?.Cancel()); } + protected void RestartIntro() + { + AddStep("restart sequence", () => + { + logo.FinishTransforms(); + logo.IsTracking = false; + + IntroStack?.Expire(); + + Add(IntroStack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both, + }); + + IntroStack.Push(Intro = CreateScreen()); + }); + } + + protected void WaitForMenu() + { + AddUntilStep("wait for menu", () => Intro.DidLoadMenu); + } + protected abstract IntroScreen CreateScreen(); } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroChristmas.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroChristmas.cs new file mode 100644 index 0000000000..0398b4fbb6 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroChristmas.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 NUnit.Framework; +using osu.Game.Screens.Menu; +using osu.Game.Seasonal; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public partial class TestSceneIntroChristmas : IntroTestScene + { + protected override bool IntroReliesOnTrack => true; + protected override IntroScreen CreateScreen() => new IntroChristmas(); + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroIntegrity.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroIntegrity.cs new file mode 100644 index 0000000000..a5590c79ae --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroIntegrity.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + [HeadlessTest] + [TestFixture] + public partial class TestSceneIntroIntegrity : IntroTestScene + { + [Test] + public virtual void TestDeletedFilesRestored() + { + RestartIntro(); + WaitForMenu(); + + AddStep("delete game files unexpectedly", () => LocalStorage.DeleteDirectory("files")); + AddStep("reset game beatmap", () => Dependencies.Get>().Value = new DummyWorkingBeatmap(Audio, null)); + AddStep("invalidate beatmap from cache", () => Dependencies.Get().Invalidate(Intro.Beatmap.Value.BeatmapSetInfo)); + + RestartIntro(); + WaitForMenu(); + + AddUntilStep("ensure track is not virtual", () => Intro.Beatmap.Value.Track is TrackBass); + } + + protected override bool IntroReliesOnTrack => true; + protected override IntroScreen CreateScreen() => new IntroTriangles(); + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs index 609bc6e166..0dfe055040 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using System.Net; using NUnit.Framework; @@ -9,10 +10,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Configuration; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Login; using osu.Game.Overlays.Settings; @@ -29,9 +32,7 @@ namespace osu.Game.Tests.Visual.Menus private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; private LoginOverlay loginOverlay = null!; - - [Resolved] - private OsuConfigManager configManager { get; set; } = null!; + private OsuConfigManager localConfig = null!; [Cached(typeof(LocalUserStatisticsProvider))] private readonly TestSceneUserPanel.TestUserStatisticsProvider statisticsProvider = new TestSceneUserPanel.TestUserStatisticsProvider(); @@ -39,6 +40,8 @@ namespace osu.Game.Tests.Visual.Menus [BackgroundDependencyLoader] private void load() { + Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage)); + Child = loginOverlay = new LoginOverlay { Anchor = Anchor.Centre, @@ -49,11 +52,12 @@ namespace osu.Game.Tests.Visual.Menus [SetUpSteps] public void SetUpSteps() { + AddStep("reset online state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.Online)); AddStep("show login overlay", () => loginOverlay.Show()); } [Test] - public void TestLoginSuccess() + public void TestLoginSuccess_EmailVerification() { AddStep("logout", () => API.Logout()); assertAPIState(APIState.Offline); @@ -89,10 +93,156 @@ namespace osu.Game.Tests.Visual.Menus AddStep("clear handler", () => dummyAPI.HandleRequest = null); assertDropdownState(UserAction.Online); - AddStep("change user state", () => dummyAPI.LocalUser.Value.Status.Value = UserStatus.DoNotDisturb); + AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb)); assertDropdownState(UserAction.DoNotDisturb); } + [Test] + public void TestLoginSuccess_TOTPVerification() + { + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword); + + AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); + AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "012345") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + } + + return false; + }); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "012345"); + assertAPIState(APIState.Online); + assertDropdownState(UserAction.Online); + + AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); }); + AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); }); + + AddStep("clear handler", () => dummyAPI.HandleRequest = null); + + assertDropdownState(UserAction.Online); + AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb)); + assertDropdownState(UserAction.DoNotDisturb); + } + + [Test] + public void TestLoginSuccess_TOTPVerification_FallbackToEmail() + { + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword); + + AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); + AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "deadbeef") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + + case VerificationMailFallbackRequest verificationMailFallbackRequest: + verificationMailFallbackRequest.TriggerSuccess(); + return true; + } + + return false; + }); + + AddStep("request fallback to email", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single(t => t.Text.ToString().Contains("email", StringComparison.InvariantCultureIgnoreCase))); + InputManager.Click(MouseButton.Left); + }); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "deadbeef"); + assertAPIState(APIState.Online); + assertDropdownState(UserAction.Online); + + AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); }); + AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); }); + + AddStep("clear handler", () => dummyAPI.HandleRequest = null); + + assertDropdownState(UserAction.Online); + AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb)); + assertDropdownState(UserAction.DoNotDisturb); + } + + [Test] + public void TestLoginSuccess_TOTPVerification_TurnedOffMidwayThrough() + { + bool firstAttemptHandled = false; + + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword); + + AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); + AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + verifySessionRequest.RequiredVerificationMethod = SessionVerificationMethod.EmailMessage; + verifySessionRequest.TriggerFailure(new WebException()); + firstAttemptHandled = true; + return true; + } + + return false; + }); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "123456"); + AddUntilStep("first verification attempt handled", () => firstAttemptHandled); + assertAPIState(APIState.RequiresSecondFactorAuth); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "deadbeef") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + } + + return false; + }); + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "deadbeef"); + assertAPIState(APIState.Online); + assertDropdownState(UserAction.Online); + } + private void assertDropdownState(UserAction state) { AddAssert($"dropdown state is {state}", () => loginOverlay.ChildrenOfType().First().Current.Value, () => Is.EqualTo(state)); @@ -188,31 +338,31 @@ namespace osu.Game.Tests.Visual.Menus public void TestUncheckingRememberUsernameClearsIt() { AddStep("logout", () => API.Logout()); - AddStep("set username", () => configManager.SetValue(OsuSetting.Username, "test_user")); - AddStep("set remember password", () => configManager.SetValue(OsuSetting.SavePassword, true)); + AddStep("set username", () => localConfig.SetValue(OsuSetting.Username, "test_user")); + AddStep("set remember password", () => localConfig.SetValue(OsuSetting.SavePassword, true)); AddStep("uncheck remember username", () => { InputManager.MoveMouseTo(loginOverlay.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); - AddAssert("remember username off", () => configManager.Get(OsuSetting.SaveUsername), () => Is.False); - AddAssert("remember password off", () => configManager.Get(OsuSetting.SavePassword), () => Is.False); - AddAssert("username cleared", () => configManager.Get(OsuSetting.Username), () => Is.Empty); + AddAssert("remember username off", () => localConfig.Get(OsuSetting.SaveUsername), () => Is.False); + AddAssert("remember password off", () => localConfig.Get(OsuSetting.SavePassword), () => Is.False); + AddAssert("username cleared", () => localConfig.Get(OsuSetting.Username), () => Is.Empty); } [Test] public void TestUncheckingRememberPasswordClearsToken() { AddStep("logout", () => API.Logout()); - AddStep("set token", () => configManager.SetValue(OsuSetting.Token, "test_token")); - AddStep("set remember password", () => configManager.SetValue(OsuSetting.SavePassword, true)); + AddStep("set token", () => localConfig.SetValue(OsuSetting.Token, "test_token")); + AddStep("set remember password", () => localConfig.SetValue(OsuSetting.SavePassword, true)); AddStep("uncheck remember token", () => { InputManager.MoveMouseTo(loginOverlay.ChildrenOfType().Last()); InputManager.Click(MouseButton.Left); }); - AddAssert("remember password off", () => configManager.Get(OsuSetting.SavePassword), () => Is.False); - AddAssert("token cleared", () => configManager.Get(OsuSetting.Token), () => Is.Empty); + AddAssert("remember password off", () => localConfig.Get(OsuSetting.SavePassword), () => Is.False); + AddAssert("token cleared", () => localConfig.Get(OsuSetting.Token), () => Is.Empty); } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs index f3ea20c1aa..cd391519f4 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Menus new APIMenuImage { Image = @"https://assets.ppy.sh/main-menu/project-loved-2@2x.png", - Url = $@"{API.WebsiteRootUrl}/home/news/2023-12-21-project-loved-december-2023", + Url = $@"{API.Endpoints.WebsiteUrl}/home/news/2023-12-21-project-loved-december-2023", } } }); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMainMenuSeasonalLighting.cs b/osu.Game.Tests/Visual/Menus/TestSceneMainMenuSeasonalLighting.cs new file mode 100644 index 0000000000..46fddf823e --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneMainMenuSeasonalLighting.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Seasonal; + +namespace osu.Game.Tests.Visual.Menus +{ + public partial class TestSceneMainMenuSeasonalLighting : OsuTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("prepare beatmap", () => + { + var setInfo = beatmaps.QueryBeatmapSet(b => b.Protected && b.Hash == IntroChristmas.CHRISTMAS_BEATMAP_SET_HASH); + + if (setInfo != null) + Beatmap.Value = beatmaps.GetWorkingBeatmap(setInfo.Value.Beatmaps.First()); + }); + + AddStep("create lighting", () => Child = new MainMenuSeasonalLighting()); + + AddStep("restart beatmap", () => + { + Beatmap.Value.Track.Start(); + Beatmap.Value.Track.Seek(4000); + }); + } + + [Test] + public void TestBasic() + { + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs index 32009dc8c2..0c11c929c4 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -185,8 +185,12 @@ namespace osu.Game.Tests.Visual.Menus AddUntilStep("track changed", () => trackChangeQueue.Count == 1); AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); - AddUntilStep("track changed", () => + AddUntilStep("new track selected", () => trackChangeQueue.Count == 2 && !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("first track selected", + () => trackChangeQueue.Count == 3 && trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs index 0d981014b8..396d2e9027 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs @@ -50,30 +50,17 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestGameplay() { + KiaiGameplayFountains fountains = null!; + AddStep("make fountains", () => { Children = new[] { - new KiaiGameplayFountains.GameplayStarFountain - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - X = 75, - }, - new KiaiGameplayFountains.GameplayStarFountain - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - X = -75, - }, + fountains = new KiaiGameplayFountains(), }; }); - AddStep("activate fountains", () => - { - ((StarFountain)Children[0]).Shoot(1); - ((StarFountain)Children[1]).Shoot(-1); - }); + AddStep("activate fountains", () => fountains.Shoot()); } [Test] diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarRulesetSelector.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarRulesetSelector.cs new file mode 100644 index 0000000000..6f1ecb9025 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarRulesetSelector.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.IO.Stores; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Visual.Menus +{ + public partial class TestSceneToolbarRulesetSelector : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets, OsuGameBase game) + { + TestRuleset.Resources = new TestResourceStore(game.Resources); + + Dependencies.CacheAs(new TestRulesetStore(rulesets)); + + Child = new Container + { + RelativeSizeAxes = Axes.X, + Height = Toolbar.HEIGHT, + Child = new ToolbarRulesetSelector(), + }; + } + + private class TestRulesetStore : RulesetStore + { + public TestRulesetStore(RulesetStore store) + { + AvailableRulesets = store.AvailableRulesets.Append(new TestRuleset().RulesetInfo); + } + + public override IEnumerable AvailableRulesets { get; } + } + + private class TestRuleset : Ruleset + { + public static IResourceStore Resources { get; set; } = null!; + + public override IEnumerable GetModsFor(ModType type) => Enumerable.Empty(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => null!; + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null!; + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null!; + + public override IResourceStore CreateResourceStore() => Resources; + + public override string Description => "Test Ruleset"; + public override string ShortName => "test"; + } + + private class TestResourceStore : ResourceStore + { + public TestResourceStore(IResourceStore store) + : base(store) + { + } + + protected override IEnumerable GetFilenames(string name) => base.GetFilenames(name) + .Select(s => s.Replace("UI/ruleset-select-test", "Gameplay/failsound")); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs index 1af4af8f6b..53d909406f 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -150,6 +150,24 @@ namespace osu.Game.Tests.Visual.Menus }); }); + // cross-reference: `TestSceneOverallRanking.TestRoundingTreatment()`. + AddStep("Test rounding treatment", () => + { + var transientUpdateDisplay = this.ChildrenOfType().Single(); + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( + new ScoreInfo(), + new UserStatistics + { + GlobalRank = 111_111, + PP = 5071.495M + }, + new UserStatistics + { + GlobalRank = 111_111, + PP = 5072.99M + }); + }); + AddStep("No change 1", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 1eb08ad3c8..955737578a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -10,6 +10,7 @@ using Moq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; @@ -20,6 +21,7 @@ using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { @@ -29,11 +31,13 @@ namespace osu.Game.Tests.Visual.Multiplayer protected readonly BindableList MultiplayerUsers = new BindableList(); - protected MultiplayerGameplayLeaderboard? Leaderboard { get; private set; } + protected MultiplayerLeaderboardProvider? LeaderboardProvider { get; private set; } + + protected DrawableGameplayLeaderboard? Leaderboard { get; private set; } protected virtual MultiplayerRoomUser CreateUser(int userId) => new MultiplayerRoomUser(userId); - protected abstract MultiplayerGameplayLeaderboard CreateLeaderboard(); + protected abstract MultiplayerLeaderboardProvider CreateLeaderboardProvider(); private readonly BindableList multiplayerUserIds = new BindableList(); private readonly BindableDictionary watchedUserStates = new BindableDictionary(); @@ -124,19 +128,38 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create leaderboard", () => { - Leaderboard?.Expire(); + Clear(true); Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - LoadComponentAsync(Leaderboard = CreateLeaderboard(), Add); + LoadComponentAsync(LeaderboardProvider = CreateLeaderboardProvider(), Add); + Add(new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = [(typeof(IGameplayLeaderboardProvider), LeaderboardProvider)], + Child = Leaderboard = new DrawableGameplayLeaderboard + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); }); AddUntilStep("wait for load", () => Leaderboard!.IsLoaded); - AddStep("check watch requests were sent", () => + AddUntilStep("check watch requests were sent", () => { - foreach (var user in MultiplayerUsers) - spectatorClient.Verify(s => s.WatchUser(user.UserID), Times.Once); + try + { + foreach (var user in MultiplayerUsers) + spectatorClient.Verify(s => s.WatchUser(user.UserID), Times.Once); + + return true; + } + catch (MockException) + { + return false; + } }); } @@ -144,7 +167,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestScoreUpdates() { AddRepeatStep("update state", UpdateUserStatesRandomly, 100); - AddToggleStep("switch compact mode", expanded => Leaderboard!.Expanded.Value = expanded); + AddToggleStep("switch compact mode", collapsed => Leaderboard!.CollapseDuringGameplay.Value = collapsed); } [Test] @@ -159,10 +182,18 @@ namespace osu.Game.Tests.Visual.Multiplayer return false; }); - AddStep("check stop watching requests were sent", () => + AddUntilStep("check stop watching requests were sent", () => { - foreach (var user in MultiplayerUsers) - spectatorClient.Verify(s => s.StopWatchingUser(user.UserID), Times.Once); + try + { + foreach (var user in MultiplayerUsers) + spectatorClient.Verify(s => s.StopWatchingUser(user.UserID), Times.Once); + return true; + } + catch (MockException) + { + return false; + } }); } @@ -204,12 +235,14 @@ namespace osu.Game.Tests.Visual.Multiplayer header.Combo++; header.MaxCombo = Math.Max(header.MaxCombo, header.Combo); header.Statistics[HitResult.Meh]++; + header.TotalScore += 50; break; default: header.Combo++; header.MaxCombo = Math.Max(header.MaxCombo, header.Combo); header.Statistics[HitResult.Great]++; + header.TotalScore += 300; break; } @@ -218,3 +251,4 @@ namespace osu.Game.Tests.Visual.Multiplayer } } } + diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 2b738743ea..184bb33c2f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; @@ -36,6 +37,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; + private RulesetStore rulesets = null!; private TestMultiplayerComponents multiplayerComponents = null!; @@ -44,14 +46,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); - Add(detachedBeatmapStore); + Add(beatmapStore); } public override void SetUpSteps() @@ -61,6 +63,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("import beatmap", () => { beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + Realm.Write(r => + { + foreach (var beatmapInfo in r.All()) + beatmapInfo.OnlineMD5Hash = beatmapInfo.MD5Hash; + }); importedSet = beatmaps.GetAllUsableBeatmapSets().First(); InitialBeatmap = importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0); OtherBeatmap = importedSet.Beatmaps.Last(b => b.Ruleset.OnlineID == 0); @@ -110,5 +117,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player player && player.IsLoaded); AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs index c5fb52461a..abb491ca31 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Lounge; @@ -30,7 +31,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); - private DrawableLoungeRoom drawableRoom = null!; + private LoungeRoomPanel panel = null!; private SearchTextBox searchTextBox = null!; private readonly ManualResetEventSlim allowResponseCallback = new ManualResetEventSlim(); @@ -38,16 +39,16 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load() { - var mockLounge = new Mock(); + var mockLounge = new Mock(); mockLounge - .Setup(l => l.Join(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>())) - .Callback, Action>((_, _, _, d) => + .Setup(l => l.Join(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>())) + .Callback, Action>((_, _, _, d) => { Task.Run(() => { allowResponseCallback.Wait(10000); allowResponseCallback.Reset(); - Schedule(() => d?.Invoke("Incorrect password")); + Schedule(() => d?.Invoke("Incorrect password", new InvalidPasswordException())); }); }); @@ -73,7 +74,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Width = 500, Depth = float.MaxValue }, - drawableRoom = new DrawableLoungeRoom(room) + panel = new LoungeRoomPanel(room) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -87,16 +88,16 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestFocusViaKeyboardCommit() { - DrawableLoungeRoom.PasswordEntryPopover? popover = null; + LoungeRoomPanel.PasswordEntryPopover? popover = null; AddAssert("search textbox has focus", () => checkFocus(searchTextBox)); AddStep("click room twice", () => { - InputManager.MoveMouseTo(drawableRoom); + InputManager.MoveMouseTo(panel); InputManager.Click(MouseButton.Left); InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for popover", () => (popover = InputManager.ChildrenOfType().SingleOrDefault()) != null); + AddUntilStep("wait for popover", () => (popover = InputManager.ChildrenOfType().SingleOrDefault()) != null); AddAssert("textbox has focus", () => checkFocus(popover.ChildrenOfType().Single())); @@ -122,16 +123,16 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestFocusViaMouseCommit() { - DrawableLoungeRoom.PasswordEntryPopover? popover = null; + LoungeRoomPanel.PasswordEntryPopover? popover = null; AddAssert("search textbox has focus", () => checkFocus(searchTextBox)); AddStep("click room twice", () => { - InputManager.MoveMouseTo(drawableRoom); + InputManager.MoveMouseTo(panel); InputManager.Click(MouseButton.Left); InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for popover", () => (popover = InputManager.ChildrenOfType().SingleOrDefault()) != null); + AddUntilStep("wait for popover", () => (popover = InputManager.ChildrenOfType().SingleOrDefault()) != null); AddAssert("textbox has focus", () => checkFocus(popover.ChildrenOfType().Single())); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs index c1662bf944..2fd1268c8a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs @@ -15,6 +15,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneDrawableRoomParticipantsList : OnlinePlayTestScene { + private Room room = null!; private DrawableRoomParticipantsList list = null!; public override void SetUpSteps() @@ -23,7 +24,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create list", () => { - SelectedRoom.Value = new Room + room = new Room { Name = "test room", Host = new APIUser @@ -33,7 +34,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }; - Child = list = new DrawableRoomParticipantsList(SelectedRoom.Value) + Child = list = new DrawableRoomParticipantsList(room) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -119,7 +120,7 @@ namespace osu.Game.Tests.Visual.Multiplayer 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)); + AddStep("remove from end", () => removeUserAt(room.RecentParticipants.Count - 1)); AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); AddAssert("45 hidden users", () => list.ChildrenOfType().Single().Count == 45); @@ -138,18 +139,18 @@ namespace osu.Game.Tests.Visual.Multiplayer private void addUser(int id) { - SelectedRoom.Value!.RecentParticipants = SelectedRoom.Value!.RecentParticipants.Append(new APIUser + room.RecentParticipants = room.RecentParticipants.Append(new APIUser { Id = id, Username = $"User {id}" }).ToArray(); - SelectedRoom.Value!.ParticipantCount++; + room.ParticipantCount++; } private void removeUserAt(int index) { - SelectedRoom.Value!.RecentParticipants = SelectedRoom.Value!.RecentParticipants.Where(u => !u.Equals(SelectedRoom.Value!.RecentParticipants[index])).ToArray(); - SelectedRoom.Value!.ParticipantCount--; + room.RecentParticipants = room.RecentParticipants.Where(u => !u.Equals(room.RecentParticipants[index])).ToArray(); + room.ParticipantCount--; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 18cd720bf2..c8216c54be 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -8,6 +8,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -37,13 +38,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneDrawableRoomPlaylist : MultiplayerTestScene { + private RulesetStore rulesets = null!; private TestPlaylist playlist = null!; private BeatmapManager manager = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -105,6 +107,19 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("no item selected", () => playlist.SelectedItem.Value == null); } + [Test] + public void TestMarkCompleted() + { + createPlaylist(); + AddStep("mark some items as complete", () => + { + playlist.Items[0].MarkCompleted(); + playlist.Items[2].MarkCompleted(); + playlist.Items[3].MarkCompleted(); + playlist.Items[5].MarkCompleted(); + }); + } + [Test] public void TestSelectable() { @@ -423,6 +438,14 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } + private partial class TestPlaylist : DrawableRoomPlaylist { public new IReadOnlyDictionary> ItemMap => base.ItemMap; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index fb54b89a4b..fd589e928a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -166,7 +166,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, Y = -ScreenFooter.HEIGHT, - Current = { BindTarget = freeModSelectOverlay.SelectedMods }, + FreeMods = { BindTarget = freeModSelectOverlay.SelectedMods }, }, footer = new ScreenFooter(), }, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs index 55c9e8142f..7d3d30b9f9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs @@ -105,7 +105,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(otherBeatmap = beatmap())); AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); - AddUntilStep("selected item is new beatmap", () => (CurrentSubScreen as MultiplayerMatchSubScreen)?.SelectedItem.Value?.Beatmap.OnlineID == otherBeatmap.OnlineID); + AddUntilStep("selected item is new beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == otherBeatmap.OnlineID); } private void addItem(Func beatmap) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index 813a420cbd..e372d63fde 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -16,15 +16,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene { + private Room room = null!; + public override void SetUpSteps() { base.SetUpSteps(); AddStep("create area", () => { - SelectedRoom.Value = new Room(); - - Child = new MatchBeatmapDetailArea(SelectedRoom.Value) + Child = new MatchBeatmapDetailArea(room = new Room()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -36,9 +36,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private void createNewItem() { - SelectedRoom.Value!.Playlist = SelectedRoom.Value.Playlist.Append(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + room.Playlist = room.Playlist.Append(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { - ID = SelectedRoom.Value.Playlist.Count, + ID = room.Playlist.Count, RulesetID = new OsuRuleset().RulesetInfo.OnlineID, RequiredMods = new[] { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index 38522db4d4..39ad21d0b0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -61,9 +61,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create leaderboard", () => { - SelectedRoom.Value = new Room { RoomID = 3 }; - - Child = new MatchLeaderboard(SelectedRoom.Value) + Child = new MatchLeaderboard(new Room { RoomID = 3 }) { Origin = Anchor.Centre, Anchor = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index fb9c801fb4..2f1b768ea6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -101,15 +101,13 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUpSteps] public void SetUpSteps() { - PlaylistItem item = null!; - AddStep("reset state", () => { multiplayerClient.Invocations.Clear(); beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable(); - item = new PlaylistItem(Beatmap.Value.BeatmapInfo) + PlaylistItem item = new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID }; @@ -127,7 +125,7 @@ namespace osu.Game.Tests.Visual.Multiplayer multiplayerRoom = new MultiplayerRoom(0) { - Playlist = { TestMultiplayerClient.CreateMultiplayerPlaylistItem(item) }, + Playlist = { new MultiplayerPlaylistItem(item) }, Users = { localUser }, Host = localUser, }; @@ -139,8 +137,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(250, 50), - SelectedItem = new Bindable(item) + Size = new Vector2(250, 50) }; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 3245b3c6a9..c39708352e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -9,24 +9,28 @@ using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene { private Dictionary clocks = null!; - private MultiSpectatorLeaderboard? leaderboard; + private MultiSpectatorLeaderboardProvider? leaderboardProvider; + private DrawableGameplayLeaderboard leaderboard = null!; [SetUpSteps] public override void SetUpSteps() { base.SetUpSteps(); + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + AddStep("reset", () => { - leaderboard?.RemoveAndDisposeImmediately(); + Clear(true); clocks = new Dictionary { @@ -45,21 +49,27 @@ namespace osu.Game.Tests.Visual.Multiplayer { Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) + LoadComponentAsync(leaderboardProvider = new MultiSpectatorLeaderboardProvider(clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()), Add); + Add(new DependencyProvidingContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Expanded = { Value = true } - }, Add); + RelativeSizeAxes = Axes.Both, + CachedDependencies = [(typeof(IGameplayLeaderboardProvider), leaderboardProvider)], + Child = leaderboard = new DrawableGameplayLeaderboard + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + CollapseDuringGameplay = { Value = false } + } + }); }); - AddUntilStep("wait for load", () => leaderboard!.IsLoaded); - AddUntilStep("wait for user population", () => leaderboard.ChildrenOfType().Count() == 2); + AddUntilStep("wait for load", () => leaderboard.IsLoaded); + AddUntilStep("wait for user population", () => leaderboard.ChildrenOfType().Count() == 2); AddStep("add clock sources", () => { foreach ((int userId, var clock) in clocks) - leaderboard!.AddClock(userId, clock); + leaderboardProvider!.AddClock(userId, clock); }); } @@ -120,6 +130,6 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time); private void assertCombo(int userId, int expectedCombo) - => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.OnlineID == userId).Combo.Value == expectedCombo); + => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.OnlineID == userId).Combo.Value == expectedCombo); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index 0a3d48828e..0a042d189d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -17,8 +17,10 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; @@ -27,6 +29,7 @@ using osu.Game.Storyboards; using osu.Game.Tests.Beatmaps.IO; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -42,6 +45,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapManager beatmapManager { get; set; } = null!; private MultiSpectatorScreen spectatorScreen = null!; + private Room room = null!; private readonly List playingUsers = new List(); @@ -63,6 +67,10 @@ namespace osu.Game.Tests.Visual.Multiplayer base.SetUpSteps(); AddStep("clear playing users", () => playingUsers.Clear()); + + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); } [TestCase(1)] @@ -297,7 +305,68 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - public void TestMostInSyncUserIsAudioSource() + [Explicit("Test relies on timing of arriving frames to exercise assertions which doesn't work headless.")] + public void TestMaximisedUserIsAudioSource() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // With no frames, the synchronisation state will be TooFarAhead. + // In this state, all players should be muted. + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, true); + + // Send frames for both players. + sendFrames(PLAYER_1_ID, 20); + sendFrames(PLAYER_2_ID, 40); + + waitUntilRunning(PLAYER_1_ID); + AddStep("maximise player 1", () => + { + InputManager.MoveMouseTo(getInstance(PLAYER_1_ID)); + InputManager.Click(MouseButton.Left); + }); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + + waitUntilPaused(PLAYER_1_ID); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + + AddStep("minimise player 1", () => + { + InputManager.MoveMouseTo(getInstance(PLAYER_1_ID)); + InputManager.Click(MouseButton.Left); + }); + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, false); + + AddStep("maximise player 2", () => + { + InputManager.MoveMouseTo(getInstance(PLAYER_2_ID)); + InputManager.Click(MouseButton.Left); + }); + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, false); + + waitUntilPaused(PLAYER_2_ID); + sendFrames(PLAYER_1_ID, 60); + + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, false); + + AddStep("minimise player 2", () => + { + InputManager.MoveMouseTo(getInstance(PLAYER_2_ID)); + InputManager.Click(MouseButton.Left); + }); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + } + + [Test] + [FlakyTest] + public void TestMostInSyncUserIsAudioSourceIfNoneMaximised() { start(new[] { PLAYER_1_ID, PLAYER_2_ID }); loadSpectateScreen(); @@ -372,7 +441,8 @@ namespace osu.Game.Tests.Visual.Multiplayer sendFrames(getPlayerIds(4), 300); - AddUntilStep("wait for correct track speed", () => Beatmap.Value.Track.Rate, () => Is.EqualTo(1.5)); + AddUntilStep("wait for correct track speed", + () => this.ChildrenOfType().All(player => player.ClockAdjustmentsFromMods.AggregateTempo.Value == 1.5)); } [Test] @@ -428,6 +498,18 @@ namespace osu.Game.Tests.Visual.Multiplayer b.Storyboard.GetLayer("Background").Add(sprite); }); + [Test] + public void TestFRankDisplay() + { + int[] userIds = getPlayerIds(1); + + start(userIds); + loadSpectateScreen(); + + sendFrames(userIds, 1000); + AddUntilStep("player has F rank", () => this.ChildrenOfType().All(msp => msp.GameplayState.ScoreProcessor.Rank.Value == ScoreRank.F)); + } + private void testLeadIn(Action? applyToBeatmap = null) { start(PLAYER_1_ID); @@ -455,7 +537,7 @@ namespace osu.Game.Tests.Visual.Multiplayer applyToBeatmap?.Invoke(Beatmap.Value); - LoadScreen(spectatorScreen = new MultiSpectatorScreen(SelectedRoom.Value!, playingUsers.ToArray())); + LoadScreen(spectatorScreen = new MultiSpectatorScreen(room, playingUsers.ToArray())); }); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded)); @@ -553,7 +635,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType().Single(p => p.UserId == userId); - private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType().Single(s => s.User?.OnlineID == userId); + private DrawableGameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.Leaderboard.ChildrenOfType().Single(s => s.User?.OnlineID == 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 9213a52c0e..4c487c8288 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -33,7 +33,6 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay; -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; @@ -52,13 +51,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayer : ScreenTestScene { + private RulesetStore rulesets = null!; private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; + private BeatmapSetInfo importedSet2 = null!; private TestMultiplayerComponents multiplayerComponents = null!; private TestMultiplayerClient multiplayerClient => multiplayerComponents.MultiplayerClient; - private TestMultiplayerRoomManager roomManager => multiplayerComponents.RoomManager; [Resolved] private OsuConfigManager config { get; set; } = null!; @@ -66,14 +66,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); - Add(detachedBeatmapStore); + Add(beatmapStore); } public override void SetUpSteps() @@ -83,7 +83,15 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("import beatmap", () => { beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + importedSet2 = beatmaps.Import(CreateBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet!)!.Value.Detach(); + + Realm.Write(r => + { + foreach (var beatmapInfo in r.All()) + beatmapInfo.OnlineMD5Hash = beatmapInfo.MD5Hash; + }); }); AddStep("load multiplayer", () => LoadScreen(multiplayerComponents = new TestMultiplayerComponents())); @@ -257,7 +265,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", Playlist = @@ -271,7 +279,7 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("refresh rooms", () => this.ChildrenOfType().Single().UpdateFilter()); - AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); + AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("join room and immediately exit select", () => @@ -286,7 +294,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", Playlist = @@ -300,7 +308,7 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("refresh rooms", () => this.ChildrenOfType().Single().UpdateFilter()); - AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); + AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("join room", () => InputManager.Key(Key.Enter)); @@ -336,7 +344,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", Password = "password", @@ -351,13 +359,13 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("refresh rooms", () => this.ChildrenOfType().Single().UpdateFilter()); - AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); + 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); + LoungeRoomPanel.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()); @@ -440,7 +448,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); + ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -481,7 +489,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); + ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -522,7 +530,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); + ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -654,7 +662,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("invoke on back button", () => multiplayerComponents.OnBackButton()); - AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().UserModsSelectOverlay.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); @@ -789,7 +797,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room", () => { - roomManager.AddServerSideRoom(new Room + multiplayerClient.AddServerSideRoom(new Room { Name = "Test Room", QueueMode = QueueMode.AllPlayers, @@ -804,14 +812,14 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("refresh rooms", () => this.ChildrenOfType().Single().UpdateFilter()); - AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); + AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); AddStep("select room", () => InputManager.Key(Key.Down)); - AddStep("disable polling", () => this.ChildrenOfType().Single().TimeBetweenPolls.Value = 0); + AddStep("disable polling", () => this.ChildrenOfType().Single().TimeBetweenPolls.Value = 0); AddStep("change server-side settings", () => { - roomManager.ServerSideRooms[0].Name = "New name"; - roomManager.ServerSideRooms[0].Playlist = + multiplayerClient.ServerSideRooms[0].Name = "New name"; + multiplayerClient.ServerSideRooms[0].Playlist = [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { @@ -825,11 +833,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); - AddAssert("local room has correct settings", () => - { - var localRoom = this.ChildrenOfType().Single().Room; - return localRoom.Name == roomManager.ServerSideRooms[0].Name && localRoom.Playlist.Single().ID == 2; - }); + AddAssert("local room has correct name", () => this.ChildrenOfType().Single().Room.Name, () => Is.EqualTo(multiplayerClient.ServerSideRooms[0].Name)); + AddAssert("local room has correct playlist", () => this.ChildrenOfType().Single().Items.Single().ID, () => Is.EqualTo(2)); } [Test] @@ -926,7 +931,7 @@ namespace osu.Game.Tests.Visual.Multiplayer enterGameplay(); AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 })); - AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, @@ -958,7 +963,7 @@ namespace osu.Game.Tests.Visual.Multiplayer enterGameplay(); AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 })); - AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, @@ -1056,6 +1061,151 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("hidden is selected", () => SelectedMods.Value, () => Has.One.TypeOf(typeof(OsuModHidden))); } + [FlakyTest] + [Test] + public void TestGlobalBeatmapDoesNotChangeAtResults() + { + createRoom(() => new Room + { + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, + Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + AllowedMods = new[] { new APIMod { Acronym = "HD" } }, + }, + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 1)).BeatmapInfo) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + AllowedMods = new[] { new APIMod { Acronym = "HD" } }, + }, + ] + }); + + enterGameplay(); + + // 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) + { + double time = i; + AddUntilStep($"wait for time > {i}", () => this.ChildrenOfType().SingleOrDefault()?.CurrentTime > time); + } + + AddUntilStep("wait for results", () => multiplayerComponents.CurrentScreen is ResultsScreen); + + AddAssert("global beatmap still matches first playlist item", () => Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(multiplayerClient.ClientRoom!.Playlist[0].BeatmapID)); + AddStep("return to match", () => multiplayerComponents.Exit()); + AddAssert("global beatmap matches second playlist item", () => Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(multiplayerClient.ClientRoom!.Playlist[1].BeatmapID)); + } + + /// + /// Tests that the local user is not able to change their play style if they haven't downloaded the beatmap (beatmap carousel will be empty). + /// + [Test] + public void TestCanNotEditDifficultyIfNotDownloaded() + { + IBeatmap roomBeatmap = null!; + + createRoom(() => + { + roomBeatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); + + return new Room + { + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, + Playlist = + [ + new PlaylistItem(CreateAPIBeatmap(roomBeatmap.BeatmapInfo)) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + } + ] + }; + }); + + AddAssert("editing disallowed", () => !this.ChildrenOfType().Single().UserStyleEditingEnabled); + AddStep("import beatmap", () => beatmaps.Import(roomBeatmap.BeatmapInfo.BeatmapSet!)); + AddAssert("editing allowed", () => this.ChildrenOfType().Single().UserStyleEditingEnabled); + } + + /// + /// Test that the user selection screen is not exited when the beatmap is changed to the same set. + /// + [Test] + public void TestUserStyleSelectionDoesNotExitWhenBeatmapSetNotChanged() + { + createRoom(() => new Room + { + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + } + ] + }); + + AddStep("open user style selection", () => this.ChildrenOfType().Single().ShowUserStyleSelect()); + AddUntilStep("style selection screen opened", () => this.ChildrenOfType().SingleOrDefault()?.IsCurrentScreen() == true); + + AddStep("change beatmap", () => + { + var newItem = multiplayerClient.ServerRoom!.Playlist[0].Clone(); + var newBeatmap = importedSet.Beatmaps.Last(); + newItem.BeatmapID = newBeatmap.OnlineID; + newItem.BeatmapChecksum = newBeatmap.MD5Hash; + + multiplayerClient.EditPlaylistItem(newItem); + }); + + AddWaitStep("wait for potential beatmap change", 2); + AddAssert("style selection screen still open", () => this.ChildrenOfType().SingleOrDefault()?.IsCurrentScreen() == true); + } + + /// + /// Tests that the user selection screen is exited when the beatmap is changed to another set. + /// + [Test] + public void TestUserStyleSelectionExitedWhenBeatmapSetChanged() + { + createRoom(() => new Room + { + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + } + ] + }); + + AddStep("open user style selection", () => this.ChildrenOfType().Single().ShowUserStyleSelect()); + AddUntilStep("style selection screen opened", () => this.ChildrenOfType().SingleOrDefault()?.IsCurrentScreen() == true); + + AddStep("change beatmap set", () => + { + var newItem = multiplayerClient.ServerRoom!.Playlist[0].Clone(); + var newBeatmap = importedSet2.Beatmaps.Last(); + newItem.BeatmapID = newBeatmap.OnlineID; + newItem.BeatmapChecksum = newBeatmap.MD5Hash; + + multiplayerClient.EditPlaylistItem(newItem); + }); + + AddUntilStep("selected beatmap changed", () => Beatmap.Value.BeatmapInfo.Equals(importedSet2.Beatmaps.First())); + AddUntilStep("style selection screen closed", () => this.ChildrenOfType().SingleOrDefault()?.IsCurrentScreen() != true); + } + private void enterGameplay() { pressReadyButton(); @@ -1098,5 +1248,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index 2f232a6164..53e265decb 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -9,7 +9,7 @@ using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { @@ -25,27 +25,25 @@ namespace osu.Game.Tests.Visual.Multiplayer return user; } - protected override MultiplayerGameplayLeaderboard CreateLeaderboard() - { - return new TestLeaderboard(MultiplayerUsers.ToArray()) + protected override MultiplayerLeaderboardProvider CreateLeaderboardProvider() => + new TestLeaderboard(MultiplayerUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, }; - } [Test] public void TestPerUserMods() { - AddStep("first user has no mods", () => Assert.That(((TestLeaderboard)Leaderboard!).UserMods[0], Is.Empty)); + AddStep("first user has no mods", () => Assert.That(((TestLeaderboard)LeaderboardProvider!).UserMods[0], Is.Empty)); AddStep("last user has NF mod", () => { - Assert.That(((TestLeaderboard)Leaderboard!).UserMods[TOTAL_USERS - 1], Has.One.Items); - Assert.That(((TestLeaderboard)Leaderboard).UserMods[TOTAL_USERS - 1].Single(), Is.TypeOf()); + Assert.That(((TestLeaderboard)LeaderboardProvider!).UserMods[TOTAL_USERS - 1], Has.One.Items); + Assert.That(((TestLeaderboard)LeaderboardProvider).UserMods[TOTAL_USERS - 1].Single(), Is.TypeOf()); }); } - private partial class TestLeaderboard : MultiplayerGameplayLeaderboard + private partial class TestLeaderboard : MultiplayerLeaderboardProvider { public Dictionary> UserMods => UserScores.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ScoreProcessor.Mods); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index 3f1db308c0..6141820cb7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -7,6 +7,7 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Tests.Visual.Multiplayer { @@ -24,8 +25,8 @@ namespace osu.Game.Tests.Visual.Multiplayer return user; } - protected override MultiplayerGameplayLeaderboard CreateLeaderboard() => - new MultiplayerGameplayLeaderboard(MultiplayerUsers.ToArray()) + protected override MultiplayerLeaderboardProvider CreateLeaderboardProvider() => + new MultiplayerLeaderboardProvider(MultiplayerUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -39,18 +40,20 @@ namespace osu.Game.Tests.Visual.Multiplayer { LoadComponentAsync(new MatchScoreDisplay { - Team1Score = { BindTarget = Leaderboard!.TeamScores[0] }, - Team2Score = { BindTarget = Leaderboard.TeamScores[1] } + Team1Score = { BindTarget = LeaderboardProvider!.TeamScores[0] }, + Team2Score = { BindTarget = LeaderboardProvider.TeamScores[1] } }, Add); - LoadComponentAsync(new GameplayMatchScoreDisplay + GameplayMatchScoreDisplay matchScoreDisplay; + LoadComponentAsync(matchScoreDisplay = new GameplayMatchScoreDisplay { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Team1Score = { BindTarget = Leaderboard.TeamScores[0] }, - Team2Score = { BindTarget = Leaderboard.TeamScores[1] }, - Expanded = { BindTarget = Leaderboard.Expanded }, + Team1Score = { BindTarget = LeaderboardProvider.TeamScores[0] }, + Team2Score = { BindTarget = LeaderboardProvider.TeamScores[1] }, }, Add); + + Leaderboard!.CollapseDuringGameplay.BindValueChanged(_ => matchScoreDisplay.Expanded.Value = !Leaderboard.CollapseDuringGameplay.Value); }); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs index 9951f62c77..f1915233e0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs @@ -16,49 +16,36 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene + public partial class TestSceneMultiplayerLoungeSubScreen : MultiplayerTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - - private LoungeSubScreen loungeScreen = null!; - private Room? lastJoinedRoom; - private string? lastJoinedPassword; + private MultiplayerLoungeSubScreen loungeScreen = null!; 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)); + createRooms(GenerateRooms(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); + AddAssert("room joined", () => MultiplayerClient.RoomJoined); } [Test] public void TestPopoverHidesOnBackButton() { - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(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()); + AddUntilStep("password prompt appeared", () => InputManager.ChildrenOfType().Any()); AddAssert("textbox has focus", () => InputManager.FocusedDrawable is OsuPasswordTextBox); @@ -66,91 +53,104 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("textbox lost focus", () => InputManager.FocusedDrawable is SearchTextBox); AddStep("hit escape", () => InputManager.Key(Key.Escape)); - AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] public void TestPopoverHidesOnLeavingScreen() { - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(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()); + AddUntilStep("password prompt appeared", () => InputManager.ChildrenOfType().Any()); AddStep("exit screen", () => Stack.Exit()); - AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] public void TestJoinRoomWithIncorrectPasswordViaButton() { - DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; + LoungeRoomPanel.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(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); + 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()); + AddAssert("still at lounge", () => loungeScreen.IsCurrentScreen()); AddUntilStep("password prompt still visible", () => passwordEntryPopover!.State.Value == Visibility.Visible); AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] public void TestJoinRoomWithIncorrectPasswordViaEnter() { - DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; + LoungeRoomPanel.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(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); + 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()); + AddAssert("still at lounge", () => loungeScreen.IsCurrentScreen()); AddUntilStep("password prompt still visible", () => passwordEntryPopover!.State.Value == Visibility.Visible); AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); + + AddAssert("room not joined", () => !MultiplayerClient.RoomJoined); } [Test] public void TestJoinRoomWithCorrectPassword() { - DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; + LoungeRoomPanel.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(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); + 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"); + AddUntilStep("room joined", () => MultiplayerClient.RoomJoined); } [Test] public void TestJoinRoomWithPasswordViaKeyboardOnly() { - DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; + LoungeRoomPanel.PasswordEntryPopover? passwordEntryPopover = null; - AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + createRooms(GenerateRooms(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); + 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"); + AddAssert("room joined", () => MultiplayerClient.RoomJoined); } - private void onRoomJoined(Room room, string? password) + private void createRooms(params Room[] rooms) { - lastJoinedRoom = room; - lastJoinedPassword = password; + AddStep("create rooms", () => + { + foreach (var room in rooms) + API.Queue(new CreateRoomRequest(room)); + }); + + AddStep("refresh lounge", () => loungeScreen.RefreshRooms()); } + + protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new MultiplayerTestSceneDependencies(); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs index edeb1708e0..a6ce03c129 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -1,11 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; namespace osu.Game.Tests.Visual.Multiplayer @@ -18,22 +18,33 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create footer", () => { - Child = new PopoverContainer + MultiplayerBeatmapAvailabilityTracker tracker = new MultiplayerBeatmapAvailabilityTracker(); + + Child = new DependencyProvidingContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Child = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = 50, - Child = new MultiplayerMatchFooter + CachedDependencies = + [ + (typeof(OnlinePlayBeatmapAvailabilityTracker), tracker) + ], + Children = + [ + tracker, + new PopoverContainer { - SelectedItem = new Bindable() + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + 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 2a5f16d091..e6f3d7e5ac 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Platform; using osu.Framework.Screens; @@ -39,6 +40,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private TestMultiplayerMatchSongSelect songSelect = null!; private Live importedBeatmapSet = null!; + private Room room = null!; [Resolved] private OsuConfigManager configManager { get; set; } = null!; @@ -46,28 +48,38 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray()))!; - Add(detachedBeatmapStore); + Add(beatmapStore); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); } private void setUp() { - AddStep("reset", () => + AddStep("create song select", () => { Ruleset.Value = new OsuRuleset().RulesetInfo; Beatmap.SetDefault(); SelectedMods.SetDefault(); + + LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(room)); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value!))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } @@ -137,8 +149,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create song select", () => { - SelectedRoom.Value!.Playlist.Single().RulesetID = 2; - songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value, SelectedRoom.Value.Playlist.Single()); + room.Playlist.Single().RulesetID = 2; + songSelect = new TestMultiplayerMatchSongSelect(room, room.Playlist.Single()); songSelect.OnLoadComplete += _ => Ruleset.Value = new TaikoRuleset().RulesetInfo; LoadScreen(songSelect); }); @@ -159,6 +171,14 @@ namespace osu.Game.Tests.Visual.Multiplayer .All(b => b.Mod.GetType() != type)); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } + private partial class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelect { public new Bindable> Mods => base.Mods; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 8ea52f8099..792bff63d3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -1,16 +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.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; @@ -30,6 +34,7 @@ using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; @@ -40,33 +45,40 @@ namespace osu.Game.Tests.Visual.Multiplayer public partial class TestSceneMultiplayerMatchSubScreen : MultiplayerTestScene { private MultiplayerMatchSubScreen screen = null!; + private RulesetStore rulesets = null!; private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; - - public TestSceneMultiplayerMatchSubScreen() - : base(false) - { - } + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); + Dependencies.CacheAs(new RealmDetachedBeatmapStore()); beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + + Realm.Write(r => + { + foreach (var beatmapInfo in r.All()) + beatmapInfo.OnlineMD5Hash = beatmapInfo.MD5Hash; + }); } - [SetUpSteps] - public void SetupSteps() + public override void SetUpSteps() { + base.SetUpSteps(); + + AddUntilStep("wait for mod select removed", () => this.ChildrenOfType().Count(), () => Is.Zero); + AddStep("load match", () => { - SelectedRoom.Value = new Room { Name = "Test Room" }; - LoadScreen(screen = new TestMultiplayerMatchSubScreen(SelectedRoom.Value!)); + room = new Room { Name = "Test Room" }; + LoadScreen(screen = new TestMultiplayerMatchSubScreen(room)); }); AddUntilStep("wait for load", () => screen.IsCurrentScreen()); @@ -77,7 +89,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -96,7 +108,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new TaikoRuleset().RulesetInfo).BeatmapInfo) { @@ -121,7 +133,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("set playlist", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -138,7 +150,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("set playlist", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) { @@ -169,7 +181,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item with allowed mod", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -188,7 +200,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); AddUntilStep("mod select contains only double time mod", - () => this.ChildrenOfType().Single().UserModsSelectOverlay + () => this.ChildrenOfType().Single() .ChildrenOfType() .SingleOrDefault(panel => panel.Visible)?.Mod is OsuModDoubleTime); } @@ -198,7 +210,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item with allowed mod", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -214,7 +226,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press toggle mod select key", () => InputManager.Key(Key.F1)); - AddUntilStep("mod select shown", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Visible); + AddUntilStep("mod select shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] @@ -222,7 +234,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item with no allowed mods", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -237,7 +249,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press toggle mod select key", () => InputManager.Key(Key.F1)); AddWaitStep("wait some", 3); - AddAssert("mod select not shown", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Hidden); + AddAssert("mod select not shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); } [Test] @@ -245,7 +257,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add two playlist items", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) { @@ -271,7 +283,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("last playlist item selected", () => { - var lastItem = this.ChildrenOfType().Single(p => p.Item.ID == MultiplayerClient.ServerAPIRoom?.Playlist.Last().ID); + var lastItem = this.ChildrenOfType() + .Single() + .ChildrenOfType() + .Single(p => p.Item.ID == MultiplayerClient.ServerAPIRoom?.Playlist.Last().ID); return lastItem.IsSelectedItem; }); } @@ -281,7 +296,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add playlist item", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { @@ -303,16 +318,160 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => RoomJoined); ClickButtonWhenEnabled(); - AddAssert("mod select shows unranked", () => this.ChildrenOfType().Single().Ranked.Value == false); + AddUntilStep("mod select shows unranked", () => this.ChildrenOfType().Single().Ranked.Value == false); AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); - AddStep("select flashlight", () => screen.UserModsSelectOverlay.ChildrenOfType().Single(m => m.Mod is ModFlashlight).TriggerClick()); + AddStep("select flashlight", () => this.ChildrenOfType().Single().ChildrenOfType().Single(m => m.Mod is ModFlashlight).TriggerClick()); AddAssert("score multiplier = 1.35", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.35).Within(0.01)); - AddStep("change flashlight setting", () => ((OsuModFlashlight)screen.UserModsSelectOverlay.SelectedMods.Value.Single()).FollowDelay.Value = 1200); + AddStep("change flashlight setting", () => ((OsuModFlashlight)this.ChildrenOfType().Single().SelectedMods.Value.Single()).FollowDelay.Value = 1200); AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); } + [Test] + public void TestChangeSettingsButtonVisibleForHost() + { + AddStep("add playlist item", () => + { + room.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + ClickButtonWhenEnabled(); + + AddUntilStep("wait for join", () => RoomJoined); + + AddUntilStep("button visible", () => this.ChildrenOfType().Single().ChangeSettingsButton.Alpha, () => Is.GreaterThan(0)); + AddStep("join other user", void () => MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID })); + AddStep("make other user host", () => MultiplayerClient.TransferHost(PLAYER_1_ID)); + AddAssert("button hidden", () => this.ChildrenOfType().Single().ChangeSettingsButton.Alpha, () => Is.EqualTo(0)); + } + + [Test] + public void TestUserModSelectUpdatesWhenNotVisible() + { + AddStep("add playlist item", () => + { + room.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + AllowedMods = [new APIMod(new OsuModFlashlight())] + } + ]; + }); + + ClickButtonWhenEnabled(); + AddUntilStep("wait for join", () => RoomJoined); + + // 1. Open the mod select overlay and enable flashlight + + ClickButtonWhenEnabled(); + AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); + AddStep("click flashlight panel", () => + { + ModPanel panel = this.ChildrenOfType().Single(p => p.Mod is OsuModFlashlight); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("flashlight mod enabled", () => MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + + // 2. Close the mod select overlay, edit the playlist to disable allowed mods, and then edit it again to re-enable allowed mods. + + AddStep("close mod select overlay", () => this.ChildrenOfType().Single().Hide()); + AddUntilStep("mod select overlay not present", () => !this.ChildrenOfType().Single().IsPresent); + AddStep("disable allowed mods", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0]) + { + AllowedMods = [] + }))); + // This would normally be done as part of the above operation with an actual server. + AddStep("disable user mods", () => MultiplayerClient.ChangeUserMods(API.LocalUser.Value.OnlineID, Array.Empty())); + AddUntilStep("flashlight mod disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + AddStep("re-enable allowed mods", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0]) + { + AllowedMods = [new APIMod(new OsuModFlashlight())] + }))); + AddAssert("flashlight mod still disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + + // 3. Open the mod select overlay, check that the flashlight mod panel is deactivated. + + ClickButtonWhenEnabled(); + AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); + AddAssert("flashlight mod still disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + AddAssert("flashlight mod panel not activated", () => !this.ChildrenOfType().Single(p => p.Mod is OsuModFlashlight).Active.Value); + } + + [Test] + public void TestStartCountdown() + { + AddStep("set playlist", () => + { + room.Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + + ClickButtonWhenEnabled(); + + AddUntilStep("wait for room join", () => RoomJoined); + + AddStep("click countdown button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("start a countdown", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single().ChildrenOfType private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () => { - MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) + MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) { Expired = expired, PlayedAt = DateTimeOffset.Now @@ -266,7 +268,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void assertQueueTabCount(int count) { - string queueTabText = count > 0 ? $"Queue ({count})" : "Queue"; + string queueTabText = count > 0 ? $"Up next ({count})" : "Up next"; AddUntilStep($"Queue tab shows \"{queueTabText}\"", () => { return this.ChildrenOfType.OsuTabItem>() @@ -290,5 +292,13 @@ namespace osu.Game.Tests.Visual.Multiplayer .Single() .Items.Any(i => i.ID == playlistItemId); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs new file mode 100644 index 0000000000..9123f63f56 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Gameplay; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public partial class TestSceneMultiplayerPositionDisplay : OsuTestScene + { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private GameplayLeaderboardScore score = null!; + + private readonly Bindable position = new Bindable(8); + + private TestGameplayLeaderboardProvider leaderboardProvider = null!; + private MultiplayerPositionDisplay display = null!; + private GameplayState gameplayState = null!; + + private const int player_count = 32; + + [Test] + public void TestAppearance() + { + AddStep("create content", () => + { + Children = new Drawable[] + { + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(IGameplayLeaderboardProvider), leaderboardProvider = new TestGameplayLeaderboardProvider()), + (typeof(GameplayState), gameplayState = TestGameplayState.Create(new OsuRuleset())) + ], + Child = display = new MultiplayerPositionDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + + score = leaderboardProvider.CreateLeaderboardScore(new BindableLong(), API.LocalUser.Value, true); + score.Position.BindTo(position); + + for (int i = 0; i < player_count - 1; i++) + { + var r = leaderboardProvider.CreateRandomScore(new APIUser()); + r.Position.Value = i; + } + }); + + AddSliderStep("set score position", 1, player_count, position.Value!.Value, r => position.Value = r); + AddStep("unset position", () => position.Value = null); + + AddStep("toggle leaderboardProvider on", () => config.SetValue(OsuSetting.GameplayLeaderboard, true)); + AddUntilStep("display visible", () => display.Alpha, () => Is.EqualTo(1)); + + AddStep("toggle leaderboardProvider off", () => config.SetValue(OsuSetting.GameplayLeaderboard, false)); + AddUntilStep("display hidden", () => display.Alpha, () => Is.EqualTo(0)); + + AddStep("enter break", () => ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Break); + AddUntilStep("display visible", () => display.Alpha, () => Is.EqualTo(1)); + + AddStep("exit break", () => ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing); + AddUntilStep("display hidden", () => display.Alpha, () => Is.EqualTo(0)); + + AddStep("toggle leaderboardProvider on", () => config.SetValue(OsuSetting.GameplayLeaderboard, true)); + AddUntilStep("display visible", () => display.Alpha, () => Is.EqualTo(1)); + + AddStep("change local user", () => ((DummyAPIAccess)API).LocalUser.Value = new GuestUser()); + AddUntilStep("display hidden", () => display.Alpha, () => Is.EqualTo(0)); + } + + [Test] + public void TestTwoPlayers() + { + AddStep("create content", () => + { + Children = new Drawable[] + { + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(IGameplayLeaderboardProvider), leaderboardProvider = new TestGameplayLeaderboardProvider()), + (typeof(GameplayState), gameplayState = TestGameplayState.Create(new OsuRuleset())) + ], + Child = display = new MultiplayerPositionDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + + score = leaderboardProvider.CreateLeaderboardScore(new BindableLong(), API.LocalUser.Value, true); + score.Position.BindTo(position); + + var r = leaderboardProvider.CreateRandomScore(new APIUser()); + r.Position.Value = 1; + }); + + AddStep("first place", () => position.Value = 1); + AddStep("second place", () => position.Value = 2); + } + + public class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider + { + public BindableList Scores { get; } = new BindableList(); + + public GameplayLeaderboardScore CreateRandomScore(APIUser user) => CreateLeaderboardScore(new BindableLong(RNG.Next(0, 5_000_000)), user); + + public GameplayLeaderboardScore CreateLeaderboardScore(BindableLong totalScore, APIUser user, bool isTracked = false) + { + var score = new GameplayLeaderboardScore(user, isTracked, totalScore); + Scores.Add(score); + return score; + } + + IBindableList IGameplayLeaderboardProvider.Scores => Scores; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index 3ef2e4ecf4..2f54551fa8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; @@ -26,14 +27,16 @@ namespace osu.Game.Tests.Visual.Multiplayer public partial class TestSceneMultiplayerQueueList : MultiplayerTestScene { private MultiplayerQueueList playlist = null!; + private RulesetStore rulesets = null!; private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; private BeatmapInfo importedBeatmap = null!; + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -42,15 +45,21 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.SetUpSteps(); + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); + AddStep("create playlist", () => { - Child = playlist = new MultiplayerQueueList(SelectedRoom.Value!) + Child = playlist = new MultiplayerQueueList { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(500, 300), + Size = new Vector2(500, 300) }; + playlist.Items.ReplaceRange(0, playlist.Items.Count, MultiplayerClient.ClientAPIRoom!.Playlist); + MultiplayerClient.ClientAPIRoom!.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(Room.Playlist)) @@ -127,13 +136,25 @@ namespace osu.Game.Tests.Visual.Multiplayer assertDeleteButtonVisibility(1, false); } + [Test] + public void TestChangeExistingItem() + { + AddStep("change beatmap", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem + { + ID = playlist.Items[0].ID, + BeatmapID = 1337 + }).WaitSafely()); + + AddUntilStep("first playlist item has new beatmap", () => playlist.Items[0].Beatmap.OnlineID, () => Is.EqualTo(1337)); + } + private void addPlaylistItem(Func userId) { long itemId = -1; AddStep("add playlist item", () => { - MultiplayerPlaylistItem item = TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); + MultiplayerPlaylistItem item = new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); MultiplayerClient.AddUserPlaylistItem(userId(), item).WaitSafely(); @@ -149,5 +170,13 @@ namespace osu.Game.Tests.Visual.Multiplayer var button = playlist.ChildrenOfType().ElementAtOrDefault(index); return (button?.Alpha > 0) == visible; }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs new file mode 100644 index 0000000000..059af2484d --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public partial class TestSceneMultiplayerSkipOverlay : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add skip overlay", () => + { + GameplayClockContainer gameplayClockContainer; + + var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new MultiplayerSkipOverlay(120000) + }, + }; + + gameplayClockContainer.Start(); + }); + + AddStep("set playing state", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Playing)); + } + + [Test] + public void TestSkip() + { + for (int i = 0; i < 4; i++) + { + int i2 = i; + + AddStep($"join user {i2}", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = i2, + Username = $"User {i2}" + }); + + MultiplayerClient.ChangeUserState(i2, MultiplayerUserState.Playing); + }); + } + + AddStep("local user votes", () => MultiplayerClient.VoteToSkipIntro().WaitSafely()); + + for (int i = 0; i < 4; i++) + { + int i2 = i; + AddStep($"user {i2} votes", () => MultiplayerClient.UserVoteToSkipIntro(i2).WaitSafely()); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index 1429f86164..fe9ea632cf 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -5,8 +5,8 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -18,6 +18,8 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Tests.Resources; using osuTK; @@ -28,14 +30,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerSpectateButton spectateButton = null!; private MatchStartControl startControl = null!; + private Room room = null!; private BeatmapSetInfo importedSet = null!; + private RulesetStore rulesets = null!; private BeatmapManager beatmaps = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); @@ -46,40 +50,52 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.SetUpSteps(); + AddStep("create room", () => room = CreateDefaultRoom()); + AddStep("join room", () => JoinRoom(room)); + WaitForJoined(); + AddStep("create button", () => { - PlaylistItem item = SelectedRoom.Value!.Playlist.First(); - - AvailabilityTracker.SelectedItem.Value = item; - importedSet = beatmaps.GetAllUsableBeatmapSets().First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); - Child = new PopoverContainer + MultiplayerBeatmapAvailabilityTracker tracker = new MultiplayerBeatmapAvailabilityTracker(); + + Child = new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + CachedDependencies = + [ + (typeof(OnlinePlayBeatmapAvailabilityTracker), tracker) + ], + Children = + [ + tracker, + new PopoverContainer { - spectateButton = new MultiplayerSpectateButton + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), - SelectedItem = new Bindable(item) - }, - startControl = new MatchStartControl - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), - SelectedItem = new Bindable(item) + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + spectateButton = new MultiplayerSpectateButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50) + }, + startControl = new MatchStartControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50) + } + } } } - } + ] }; }); } @@ -148,5 +164,13 @@ namespace osu.Game.Tests.Visual.Multiplayer private void assertReadyButtonEnablement(bool shouldBeEnabled) => AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => startControl.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerUserModDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerUserModDisplay.cs new file mode 100644 index 0000000000..02b97c6dd6 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerUserModDisplay.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.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.OnlinePlay.Multiplayer; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public partial class TestSceneMultiplayerUserModDisplay : MultiplayerTestScene + { + private MultiplayerUserModDisplay modDisplay = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add display", () => Child = modDisplay = new MultiplayerUserModDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + [Test] + public void TestChangeMods() + { + AddStep("set DT", () => MultiplayerClient.ChangeUserMods([new OsuModDoubleTime()]).WaitSafely()); + AddUntilStep("mod displayed", () => modDisplay.ChildrenOfType().Count() == 1); + + AddStep("set DT, HR", () => MultiplayerClient.ChangeUserMods([new OsuModDoubleTime(), new OsuModHardRock()]).WaitSafely()); + AddUntilStep("mods displayed", () => modDisplay.ChildrenOfType().Count() == 2); + + AddStep("set no mods", () => MultiplayerClient.ChangeUserMods(Enumerable.Empty()).WaitSafely()); + AddUntilStep("no mods displayed", () => !modDisplay.ChildrenOfType().Any()); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index fa1909254a..7135ff930d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -6,8 +6,13 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; @@ -16,48 +21,54 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestScenePlaylistsSongSelect : OnlinePlayTestScene { + private RulesetStore rulesets = null!; private BeatmapManager manager = null!; private TestPlaylistsSongSelect songSelect = null!; + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - DetachedBeatmapStore detachedBeatmapStore; + BeatmapStore beatmapStore; - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); var beatmapSet = TestResources.CreateTestBeatmapSetInfo(); manager.Import(beatmapSet); - Add(detachedBeatmapStore); + Add(beatmapStore); } public override void SetUpSteps() { base.SetUpSteps(); + AddUntilStep("wait for mod select removed", () => this.ChildrenOfType().Count(), () => Is.Zero); + AddStep("reset", () => { - SelectedRoom.Value = new Room(); + room = new Room(); Ruleset.Value = new OsuRuleset().RulesetInfo; Beatmap.SetDefault(); SelectedMods.Value = Array.Empty(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(SelectedRoom.Value!))); + AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(room))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } @@ -65,14 +76,14 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestItemAddedIfEmptyOnStart() { AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] public void TestItemAddedWhenCreateNewItemClicked() { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] @@ -80,7 +91,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] @@ -88,7 +99,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddAssert("playlist has 2 items", () => SelectedRoom.Value!.Playlist.Count == 2); + AddAssert("playlist has 2 items", () => room.Playlist.Count == 2); } [Test] @@ -96,10 +107,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("rearrange", () => SelectedRoom.Value!.Playlist = SelectedRoom.Value!.Playlist.Skip(1).Append(SelectedRoom.Value!.Playlist[0]).ToArray()); + AddStep("rearrange", () => room.Playlist = room.Playlist.Skip(1).Append(room.Playlist[0]).ToArray()); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddAssert("new item has id 2", () => SelectedRoom.Value!.Playlist.Last().ID == 2); + AddAssert("new item has id 2", () => room.Playlist.Last().ID == 2); } /// @@ -115,13 +126,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("item 1 has rate 1.5", () => { - var mod = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); + var mod = (OsuModDoubleTime)room.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(1.5, mod.SpeedChange.Value); }); AddAssert("item 2 has rate 2", () => { - var mod = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.Last().RequiredMods[0].ToMod(new OsuRuleset()); + var mod = (OsuModDoubleTime)room.Playlist.Last().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(2, mod.SpeedChange.Value); }); } @@ -147,15 +158,53 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("change stored mod rate", () => mod.SpeedChange.Value = 2); AddAssert("item has rate 1.5", () => { - var m = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); + var m = (OsuModDoubleTime)room.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(1.5, m.SpeedChange.Value); }); } + [Test] + public void TestFreeModSelectionDisable() + { + FooterButtonFreeMods freeMods = null!; + + AddAssert("freestyle enabled", () => songSelect.Freestyle.Value, () => Is.True); + AddStep("click icon in free mods button", () => + { + freeMods = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("mod select not visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("toggle freestyle off", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("freestyle disabled", () => songSelect.Freestyle.Value, () => Is.False); + AddStep("click icon in free mods button", () => + { + InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("mod select visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } + private partial class TestPlaylistsSongSelect : PlaylistsSongSelect { public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; + public new IBindable Freestyle => base.Freestyle; + public TestPlaylistsSongSelect(Room room) : base(room) { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomListing.cs similarity index 51% rename from osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs rename to osu.Game.Tests/Visual/Multiplayer/TestSceneRoomListing.cs index 797b69ec72..7f6fb97e0c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomListing.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -17,11 +18,11 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneLoungeRoomsContainer : OnlinePlayTestScene + public partial class TestSceneRoomListing : OnlinePlayTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - - private RoomsContainer container = null!; + private BindableList rooms = null!; + private IBindable selectedRoom = null!; + private RoomListing container = null!; public override void SetUpSteps() { @@ -29,17 +30,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create container", () => { + rooms = new BindableList(); + selectedRoom = new Bindable(); + Child = new PopoverContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 0.5f, - - Child = container = new RoomsContainer + Child = container = new RoomListing { - SelectedRoom = { BindTarget = SelectedRoom } + RelativeSizeAxes = Axes.Both, + Rooms = { BindTarget = rooms }, + SelectedRoom = { BindTarget = selectedRoom } } }; }); @@ -48,57 +52,58 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestBasicListChanges() { - AddStep("add rooms", () => RoomManager.AddRooms(5, withSpotlightRooms: true)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(5, withPinnedRooms: true))); - AddAssert("has 5 rooms", () => container.Rooms.Count == 5); + AddAssert("has 5 rooms", () => container.DrawableRooms.Count == 5); - AddAssert("all spotlights at top", () => container.Rooms - .SkipWhile(r => r.Room.Category == RoomCategory.Spotlight) - .All(r => r.Room.Category == RoomCategory.Normal)); + AddAssert("all pinned at top", () => container.DrawableRooms + .SkipWhile(r => r.Room.Pinned) + .All(r => !r.Room.Pinned)); - AddStep("remove first room", () => RoomManager.RemoveRoom(RoomManager.Rooms.First(r => r.RoomID == 0))); - AddAssert("has 4 rooms", () => container.Rooms.Count == 4); - AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID != 0)); + AddStep("remove first room", () => rooms.RemoveAt(0)); + AddAssert("has 4 rooms", () => container.DrawableRooms.Count == 4); + AddAssert("first room removed", () => container.DrawableRooms.All(r => r.Room.RoomID != 0)); - AddStep("select first room", () => container.Rooms.First().TriggerClick()); - AddAssert("first spotlight selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category == RoomCategory.Spotlight))); + AddStep("select first room", () => container.DrawableRooms.First().TriggerClick()); + AddAssert("first pinned room selected", () => checkRoomSelected(rooms.First(r => r.Pinned))); - AddStep("remove last room", () => RoomManager.RemoveRoom(RoomManager.Rooms.MinBy(r => r.RoomID)!)); - AddAssert("first spotlight still selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category == RoomCategory.Spotlight))); + AddStep("remove last room", () => rooms.RemoveAt(rooms.Count - 1)); + AddAssert("first pinned room selected", () => checkRoomSelected(rooms.First(r => r.Pinned))); - AddStep("remove spotlight room", () => RoomManager.RemoveRoom(RoomManager.Rooms.Single(r => r.Category == RoomCategory.Spotlight))); + AddStep("remove pinned rooms", () => rooms.RemoveAll(r => r.Pinned)); AddAssert("selection vacated", () => checkRoomSelected(null)); } [Test] public void TestKeyboardNavigation() { - AddStep("add rooms", () => RoomManager.AddRooms(3)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3))); AddAssert("no selection", () => checkRoomSelected(null)); press(Key.Down); - AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + AddAssert("first room selected", () => checkRoomSelected(container.DrawableRooms.First().Room)); press(Key.Up); - AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + AddAssert("first room selected", () => checkRoomSelected(container.DrawableRooms.First().Room)); press(Key.Down); press(Key.Down); - AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last())); + AddAssert("last room selected", () => checkRoomSelected(container.DrawableRooms.Last().Room)); } [Test] public void TestKeyboardNavigationAfterOrderChange() { - AddStep("add rooms", () => RoomManager.AddRooms(3)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3))); AddStep("reorder rooms", () => { - var room = RoomManager.Rooms[1]; + var room = rooms[1]; + rooms.Remove(room); - RoomManager.RemoveRoom(room); - RoomManager.AddOrUpdateRoom(room); + room.RoomID += 3; + rooms.Add(room); }); AddAssert("no selection", () => checkRoomSelected(null)); @@ -116,12 +121,12 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestClickDeselection() { - AddStep("add room", () => RoomManager.AddRooms(1)); + AddStep("add room", () => rooms.AddRange(GenerateRooms(1))); AddAssert("no selection", () => checkRoomSelected(null)); press(Key.Down); - AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + AddAssert("first room selected", () => checkRoomSelected(container.DrawableRooms.First().Room)); AddStep("click away", () => InputManager.Click(MouseButton.Left)); AddAssert("no selection", () => checkRoomSelected(null)); @@ -135,34 +140,34 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestStringFiltering() { - AddStep("add rooms", () => RoomManager.AddRooms(4)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(4))); - AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + AddUntilStep("4 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 4); - AddStep("filter one room", () => container.Filter.Value = new FilterCriteria { SearchString = "1" }); + AddStep("filter one room", () => container.Filter.Value = new FilterCriteria { SearchString = rooms.First().Name }); - AddUntilStep("1 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 1); + AddUntilStep("1 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 1); AddStep("remove filter", () => container.Filter.Value = null); - AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + AddUntilStep("4 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 4); } [Test] public void TestRulesetFiltering() { - AddStep("add rooms", () => RoomManager.AddRooms(2, new OsuRuleset().RulesetInfo)); - AddStep("add rooms", () => RoomManager.AddRooms(3, new CatchRuleset().RulesetInfo)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(2, new OsuRuleset().RulesetInfo))); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3, new CatchRuleset().RulesetInfo))); // Todo: What even is this case...? AddStep("set empty filter criteria", () => container.Filter.Value = new FilterCriteria()); - AddUntilStep("5 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 5); + AddUntilStep("5 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 5); AddStep("filter osu! rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new OsuRuleset().RulesetInfo }); - AddUntilStep("2 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 2); + AddUntilStep("2 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 2); AddStep("filter catch rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new CatchRuleset().RulesetInfo }); - AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); + AddUntilStep("3 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 3); } [Test] @@ -170,32 +175,32 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add rooms", () => { - RoomManager.AddRooms(1, withPassword: true); - RoomManager.AddRooms(1, withPassword: false); + rooms.AddRange(GenerateRooms(1, withPassword: true)); + rooms.AddRange(GenerateRooms(1, withPassword: false)); }); AddStep("apply default filter", () => container.Filter.SetDefault()); - AddUntilStep("both rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 2); + AddUntilStep("both rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 2); AddStep("filter public rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Public }); - AddUntilStep("private room hidden", () => container.Rooms.All(r => !r.Room.HasPassword)); + AddUntilStep("private room hidden", () => container.DrawableRooms.All(r => !r.Room.HasPassword)); AddStep("filter private rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Private }); - AddUntilStep("public room hidden", () => container.Rooms.All(r => r.Room.HasPassword)); + AddUntilStep("public room hidden", () => container.DrawableRooms.All(r => r.Room.HasPassword)); } [Test] public void TestPasswordProtectedRooms() { - AddStep("add rooms", () => RoomManager.AddRooms(3, withPassword: true)); + AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3, withPassword: true))); } - private bool checkRoomSelected(Room? room) => SelectedRoom.Value == room; + private bool checkRoomSelected(Room? room) => selectedRoom.Value == room; private Room? getRoomInFlow(int index) => - (container.ChildrenOfType>().First().FlowingChildren.ElementAt(index) as DrawableRoom)?.Room; + (container.ChildrenOfType>().First().FlowingChildren.ElementAt(index) as RoomPanel)?.Room; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs similarity index 50% rename from osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs rename to osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs index e5938a796c..aa9dddae4d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs @@ -14,18 +14,18 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; 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.Screens.OnlinePlay.Match; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Beatmaps; using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneDrawableRoom : OsuTestScene + public partial class TestSceneRoomPanel : OsuTestScene { [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); @@ -39,10 +39,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create rooms", () => { - PlaylistItem item1 = new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) + PlaylistItem item1 = new PlaylistItem(new APIBeatmap { - BeatmapInfo = { StarRating = 2.5 } - }.BeatmapInfo); + OnlineBeatmapSetID = 173612, + OnlineID = 502132, + }); PlaylistItem item2 = new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) { @@ -73,10 +74,26 @@ namespace osu.Game.Tests.Visual.Multiplayer Spacing = new Vector2(10), Children = new Drawable[] { + createMultiplayerPanel(new Room + { + Name = "Multiplayer room", + EndDate = DateTimeOffset.Now.AddDays(1), + Type = MatchType.HeadToHead, + Playlist = [item1], + CurrentPlaylistItem = item1 + }), createLoungeRoom(new Room { Name = "Multiplayer room", - Status = new RoomStatusOpen(), + EndDate = DateTimeOffset.Now.AddDays(1), + Type = MatchType.HeadToHead, + Playlist = [item1], + CurrentPlaylistItem = item1 + }), + createLoungeRoom(new Room + { + Name = "Pinned room", + Pinned = true, EndDate = DateTimeOffset.Now.AddDays(1), Type = MatchType.HeadToHead, Playlist = [item1], @@ -85,70 +102,80 @@ namespace osu.Game.Tests.Visual.Multiplayer createLoungeRoom(new Room { Name = "Private room", - Status = new RoomStatusOpenPrivate(), Password = "*", EndDate = DateTimeOffset.Now.AddDays(1), Type = MatchType.HeadToHead, Playlist = [item3], CurrentPlaylistItem = item3 }), - createLoungeRoom(new Room + createPlaylistRoomPanel(new Room { Name = "Playlist room with multiple beatmaps", - Status = new RoomStatusPlaying(), + Status = RoomStatus.Playing, EndDate = DateTimeOffset.Now.AddDays(1), Playlist = [item1, item2], CurrentPlaylistItem = item1 }), createLoungeRoom(new Room { - Name = "Finished room", - Status = new RoomStatusEnded(), + Name = "Playlist room with multiple beatmaps", + Status = RoomStatus.Playing, + EndDate = DateTimeOffset.Now.AddDays(1), + Playlist = [item1, item2], + CurrentPlaylistItem = item1 + }), + createLoungeRoom(new Room + { + Name = "Closing soon", + EndDate = DateTimeOffset.Now.AddSeconds(5), + }), + createLoungeRoom(new Room + { + Name = "Closed room", EndDate = DateTimeOffset.Now, }), createLoungeRoom(new Room { Name = "Spotlight room", - Status = new RoomStatusOpen(), Category = RoomCategory.Spotlight, }), createLoungeRoom(new Room { Name = "Featured artist room", - Status = new RoomStatusOpen(), Category = RoomCategory.FeaturedArtist, }), } }; }); - AddUntilStep("wait for panel load", () => rooms.Count == 6); - AddUntilStep("correct status text", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Currently playing", StringComparison.Ordinal)) == 2); - AddUntilStep("correct status text", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Ready to play", StringComparison.Ordinal)) == 4); + AddUntilStep("wait for panel load", () => rooms.Count, () => Is.EqualTo(10)); + AddUntilStep("\"currently playing\" room count correct", + () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Currently playing", StringComparison.Ordinal)), () => Is.EqualTo(4)); + AddUntilStep("\"ready to play\" room count correct", () => rooms.ChildrenOfType().Count(s => s.Text.ToString().StartsWith("Ready to play", StringComparison.Ordinal)), + () => Is.EqualTo(5)); } [Test] public void TestEnableAndDisablePassword() { - DrawableRoom drawableRoom = null!; + RoomPanel panel = null!; Room room = null!; - AddStep("create room", () => Child = drawableRoom = createLoungeRoom(room = new Room + AddStep("create room", () => Child = panel = createLoungeRoom(room = new Room { Name = "Room with password", - Status = new RoomStatusOpen(), Type = MatchType.HeadToHead, })); - AddUntilStep("wait for panel load", () => drawableRoom.ChildrenOfType().Any()); + AddUntilStep("wait for panel load", () => panel.ChildrenOfType().Any()); - AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); + AddAssert("password icon hidden", () => Precision.AlmostEquals(0, panel.ChildrenOfType().First().Alpha)); AddStep("set password", () => room.Password = "password"); - AddAssert("password icon visible", () => Precision.AlmostEquals(1, drawableRoom.ChildrenOfType().Single().Alpha)); + AddAssert("password icon visible", () => Precision.AlmostEquals(1, panel.ChildrenOfType().First().Alpha)); AddStep("unset password", () => room.Password = string.Empty); - AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); + AddAssert("password icon hidden", () => Precision.AlmostEquals(0, panel.ChildrenOfType().First().Alpha)); } [Test] @@ -162,38 +189,83 @@ namespace osu.Game.Tests.Visual.Multiplayer Spacing = new Vector2(5), Children = new[] { - new DrawableMatchRoom(new Room + new MultiplayerRoomPanel(new Room { Name = "A host-only room", QueueMode = QueueMode.HostOnly, Type = MatchType.HeadToHead, - }) - { - SelectedItem = new Bindable() - }, - new DrawableMatchRoom(new Room + RoomID = 1337, + }), + new MultiplayerRoomPanel(new Room { Name = "An all-players, team-versus room", QueueMode = QueueMode.AllPlayers, - Type = MatchType.TeamVersus - }) - { - SelectedItem = new Bindable() - }, - new DrawableMatchRoom(new Room + Type = MatchType.TeamVersus, + RoomID = 1338, + }), + new MultiplayerRoomPanel(new Room { Name = "A round-robin room", QueueMode = QueueMode.AllPlayersRoundRobin, - Type = MatchType.HeadToHead - }) - { - SelectedItem = new Bindable() - }, + Type = MatchType.HeadToHead, + RoomID = 1339, + }), } }); } - private DrawableRoom createLoungeRoom(Room room) + [Test] + public void TestRoomWithLongTitle() + { + AddStep("create rooms", () => Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new[] + { + new MultiplayerRoomPanel(new Room + { + Name = + "This room has a very very long title enough to make the external link button reach the participants list on the right side unless the test window is very wide, at which point I don't know, hi.", + QueueMode = QueueMode.HostOnly, + Type = MatchType.HeadToHead, + RoomID = 1337, + }), + } + }); + } + + [Test] + public void TestRoomWithUpdatedRoomID() + { + Room room = null!; + + AddStep("create rooms", () => Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new[] + { + new MultiplayerRoomPanel(room = new Room + { + Name = + "This room has a very very long title enough to make the external link button reach the participants list on the right side unless the test window is very wide, at which point I don't know, hi.", + QueueMode = QueueMode.HostOnly, + Type = MatchType.HeadToHead, + }), + } + }); + AddWaitStep("wait", 3); + AddStep("set room ID", () => room.RoomID = 1337); + AddWaitStep("wait", 3); + AddStep("clear room ID", () => room.RoomID = null); + } + + private RoomPanel createPlaylistRoomPanel(Room room) { room.Host ??= new APIUser { Username = "peppy", Id = 2 }; @@ -206,7 +278,42 @@ namespace osu.Game.Tests.Visual.Multiplayer }).ToArray(); } - return new DrawableLoungeRoom(room) + return new PlaylistsRoomPanel(room) + { + SelectedItem = new Bindable(room.CurrentPlaylistItem), + }; + } + + private RoomPanel createMultiplayerPanel(Room room) + { + room.Host ??= new APIUser { Username = "peppy", Id = 2 }; + + if (room.RecentParticipants.Count == 0) + { + room.RecentParticipants = Enumerable.Range(0, 20).Select(i => new APIUser + { + Id = i, + Username = $"User {i}" + }).ToArray(); + } + + return new MultiplayerRoomPanel(room); + } + + private RoomPanel createLoungeRoom(Room room) + { + room.Host ??= new APIUser { Username = "peppy", Id = 2 }; + + if (room.RecentParticipants.Count == 0) + { + room.RecentParticipants = Enumerable.Range(0, 20).Select(i => new APIUser + { + Id = i, + Username = $"User {i}" + }).ToArray(); + } + + return new LoungeRoomPanel(room) { MatchingFilter = true, SelectedRoom = selectedRoom diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs index 88afef7de2..3a941e0ed3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs @@ -1,31 +1,76 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Resources; +using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestSceneStarRatingRangeDisplay : OnlinePlayTestScene + public partial class TestSceneStarRatingRangeDisplay : OsuTestScene { - public override void SetUpSteps() + private readonly Room room = new Room(); + + protected override void LoadComplete() { - base.SetUpSteps(); + base.LoadComplete(); - AddStep("create display", () => + Child = new FillFlowContainer { - SelectedRoom.Value = new Room(); - - Child = new StarRatingRangeDisplay(SelectedRoom.Value) + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }; - }); + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(5), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Scale = new Vector2(5), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Scale = new Vector2(2), + }, + new StarRatingRangeDisplay(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + Scale = new Vector2(1), + }, + } + }; } [Test] @@ -33,12 +78,52 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("set playlist", () => { - SelectedRoom.Value!.Playlist = + room.Playlist = [ - new PlaylistItem(new BeatmapInfo { StarRating = min }), - new PlaylistItem(new BeatmapInfo { StarRating = max }), + new PlaylistItem(new BeatmapInfo { StarRating = min }) { ID = TestResources.GetNextTestID() }, + new PlaylistItem(new BeatmapInfo { StarRating = max }) { ID = TestResources.GetNextTestID() }, ]; }); } + + [Test] + public void TestRangeUsesNonExpiredItemsIfThereAreAny() + { + AddStep("set up room", () => + { + room.Playlist = + [ + new PlaylistItem(new BeatmapInfo { StarRating = 1 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 2 }) { ID = TestResources.GetNextTestID(), Expired = false }, + new PlaylistItem(new BeatmapInfo { StarRating = 3 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 4 }) { ID = TestResources.GetNextTestID(), Expired = false }, + new PlaylistItem(new BeatmapInfo { StarRating = 5 }) { ID = TestResources.GetNextTestID(), Expired = false }, + new PlaylistItem(new BeatmapInfo { StarRating = 6 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 7 }) { ID = TestResources.GetNextTestID(), Expired = true }, + ]; + }); + AddAssert("minimum is 2.00*", () => this.ChildrenOfType().ElementAt(0).Current.Value.Stars, () => Is.EqualTo(2)); + AddAssert("maximum is 5.00*", () => this.ChildrenOfType().ElementAt(1).Current.Value.Stars, () => Is.EqualTo(5)); + } + + [Test] + public void TestRangeUsesAllItemsIfAllAreExpired() + { + AddStep("set up room", () => + { + room.Playlist = + [ + new PlaylistItem(new BeatmapInfo { StarRating = 1 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 2 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 3 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 4 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 5 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 6 }) { ID = TestResources.GetNextTestID(), Expired = true }, + new PlaylistItem(new BeatmapInfo { StarRating = 7 }) { ID = TestResources.GetNextTestID(), Expired = true }, + ]; + }); + AddAssert("minimum is 1.00*", () => this.ChildrenOfType().ElementAt(0).Current.Value.Stars, () => Is.EqualTo(1)); + AddAssert("maximum is 7.00*", () => this.ChildrenOfType().ElementAt(1).Current.Value.Stars, () => Is.EqualTo(7)); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs index 05136ebee1..2e08b494bd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -27,6 +28,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneTeamVersus : ScreenTestScene { + private RulesetStore rulesets = null!; private BeatmapManager beatmaps = null!; private BeatmapSetInfo importedSet = null!; @@ -37,7 +39,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -182,5 +184,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs index d76e0290ef..c7499c98b5 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -26,8 +26,8 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; using osuTK.Input; @@ -110,7 +110,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("exit", () => getEditor().Exit()); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.Beatmap.Value is DummyWorkingBeatmap); } @@ -165,14 +165,13 @@ namespace osu.Game.Tests.Visual.Navigation } [Test] - [Solo] public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() { prepareBeatmap(); AddStep("switch ruleset at song select", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddStep("open editor", () => ((SoloSongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); AddAssert("editor ruleset is osu!", () => Game.Ruleset.Value, () => Is.EqualTo(new OsuRuleset().RulesetInfo)); @@ -188,8 +187,8 @@ namespace osu.Game.Tests.Visual.Navigation }); AddAssert("gameplay ruleset is osu!", () => Game.Ruleset.Value, () => Is.EqualTo(new OsuRuleset().RulesetInfo)); - AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(SoloSongSelect).Yield())); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddAssert("previous ruleset restored", () => Game.Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); } @@ -290,8 +289,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("user request play", () => Game.MusicController.Play(requestedByUser: true)); AddUntilStep("music still stopped", () => !Game.MusicController.IsPlaying); - AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(SoloSongSelect).Yield())); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddUntilStep("wait for music playing", () => Game.MusicController.IsPlaying); AddStep("user request stop", () => Game.MusicController.Stop(requestedByUser: true)); @@ -353,13 +352,13 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); AddUntilStep("wait for song select", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.BeatmapSetsLoaded); + && Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect + && songSelect.CarouselItemsPresented); } private void openEditor() { - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddStep("open editor", () => ((SoloSongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs index 43b160250c..0ccfb5a4e3 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs @@ -5,7 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation @@ -40,7 +40,7 @@ namespace osu.Game.Tests.Visual.Navigation InputManager.Key(Key.P); }); - AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); } [Test] @@ -55,7 +55,7 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("state is play", () => buttons.State == ButtonSystemState.Play); AddStep("press P", () => InputManager.Key(Key.P)); - AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("entered song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs index 3a3af43cb1..4f27d9b323 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs @@ -15,7 +15,7 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays.Settings.Sections.Input; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; @@ -54,10 +54,10 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); - AddUntilStep("wait for carousel load", () => songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for carousel load", () => songSelect.CarouselItemsPresented); AddStep("enter gameplay", () => InputManager.Key(Key.Enter)); @@ -94,7 +94,7 @@ namespace osu.Game.Tests.Visual.Navigation .AsEnumerable() .First(k => k.RulesetName == "osu" && k.ActionInt == 0); - private Screens.Select.SongSelect songSelect => Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect; + private SoloSongSelect songSelect => Game.ScreenStack.CurrentScreen as SoloSongSelect; private Player player => Game.ScreenStack.CurrentScreen as Player; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs index a89f5fb647..0a4349d73f 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs @@ -1,12 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using NUnit.Framework; using osu.Framework.Extensions; using osu.Game.Configuration; using osu.Game.Screens.Play; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; @@ -58,7 +57,11 @@ namespace osu.Game.Tests.Visual.Navigation // First scroll makes volume controls appear, second adjusts volume. AddRepeatStep("Adjust volume using mouse wheel", () => InputManager.ScrollVerticalBy(5), 10); - AddAssert("Volume is still zero", () => Game.Audio.Volume.Value == 0); + AddAssert("Volume is still zero", () => Game.Audio.Volume.Value, () => Is.Zero); + + AddStep("Pause", () => InputManager.PressKey(Key.Escape)); + AddRepeatStep("Adjust volume using mouse wheel", () => InputManager.ScrollVerticalBy(5), 10); + AddAssert("Volume is above zero", () => Game.Audio.Volume.Value > 0); } [Test] @@ -80,10 +83,10 @@ namespace osu.Game.Tests.Visual.Navigation private void loadToPlayerNonBreakTime() { - Player player = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSceneScreenNavigation.TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Player? player = null; + SoloSongSelect songSelect = null!; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -95,7 +98,7 @@ namespace osu.Game.Tests.Visual.Navigation return (player = Game.ScreenStack.CurrentScreen as Player) != null; }); - AddUntilStep("wait for play time active", () => !player.IsBreakTime.Value); + AddUntilStep("wait for play time active", () => player!.IsBreakTime.Value, () => Is.False); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs index de303fe074..f356873220 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs @@ -25,6 +25,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Skinning; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation @@ -83,6 +84,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestCursorHidesWhenIdle() { + AddStep("move mouse inside game bounds", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.TopLeft + new Vector2(20))); AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); AddUntilStep("wait until idle", () => Game.IsIdle.Value); AddUntilStep("menu cursor hidden", () => Game.GlobalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 5fe4bb9340..04d7b15295 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -17,9 +17,9 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; -using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { @@ -44,17 +44,17 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelect() { - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); - AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); + AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(SoloSongSelect) })); AddAssert("did perform", () => actionPerformed); - AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); + AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); } [Test] public void TestPerformAtMenuFromSongSelect() { - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -69,8 +69,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("Press enter", () => InputManager.Key(Key.Enter)); AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen is PlayerLoader); - AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); - AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(SoloSongSelect) })); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); AddAssert("did perform", () => actionPerformed); } @@ -257,7 +257,7 @@ namespace osu.Game.Tests.Visual.Navigation private void importAndWaitForSongSelect() { AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddUntilStep("beatmap updated", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID == 241526); } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index f036b4b3ef..1dd39e5bf9 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -16,7 +16,7 @@ using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; namespace osu.Game.Tests.Visual.Navigation { @@ -81,11 +81,9 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(osuImport); var maniaImport = importBeatmap(2, new ManiaRuleset().RulesetInfo); - confirmBeatmapInSongSelect(maniaImport); presentAndConfirm(maniaImport); var catchImport = importBeatmap(3, new CatchRuleset().RulesetInfo); - confirmBeatmapInSongSelect(catchImport); presentAndConfirm(catchImport); // Ruleset is always changed. @@ -103,11 +101,9 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(osuImport); var maniaImport = importBeatmap(2, new ManiaRuleset().RulesetInfo); - confirmBeatmapInSongSelect(maniaImport); presentAndConfirm(maniaImport); var catchImport = importBeatmap(3, new CatchRuleset().RulesetInfo); - confirmBeatmapInSongSelect(catchImport); presentAndConfirm(catchImport); // force ruleset to osu!mania @@ -178,14 +174,14 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep("wait for carousel loaded", () => { - var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; + var songSelect = (SoloSongSelect)Game.ScreenStack.CurrentScreen; return songSelect.ChildrenOfType().SingleOrDefault()?.IsLoaded == true; }); AddUntilStep("beatmap in song select", () => { - var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; - return songSelect.ChildrenOfType().Single().BeatmapSets.Any(b => b.MatchesOnlineID(getImport())); + var songSelect = (SoloSongSelect)Game.ScreenStack.CurrentScreen; + return songSelect.ChildrenOfType().Single().GetCarouselItems()!.Any(i => i.Model is GroupedBeatmapSet gbs && gbs.BeatmapSet.MatchesOnlineID(getImport())); }); } @@ -193,7 +189,7 @@ namespace osu.Game.Tests.Visual.Navigation { AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID, () => Is.EqualTo(getImport().OnlineID)); AddAssert("correct ruleset selected", () => Game.Ruleset.Value, () => Is.EqualTo(getImport().Beatmaps.First().Ruleset)); } @@ -203,7 +199,7 @@ namespace osu.Game.Tests.Visual.Navigation Predicate pred = b => b.OnlineID == importedID * 1024 + 2; AddStep("present difficulty", () => Game.PresentBeatmap(getImport(), pred)); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(importedID * 1024 + 2)); AddAssert("correct ruleset selected", () => Game.Ruleset.Value.OnlineID, () => Is.EqualTo(expectedRulesetOnlineID ?? getImport().Beatmaps.First().Ruleset.OnlineID)); } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 2c2335de13..fa337a3ec2 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -18,7 +18,8 @@ using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; +using FilterControl = osu.Game.Screens.SelectV2.FilterControl; namespace osu.Game.Tests.Visual.Navigation { @@ -96,9 +97,9 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelectWithFilter([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); - AddStep("filter to nothing", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).FilterControl.CurrentTextSearch.Value = "fdsajkl;fgewq"); + AddStep("filter to nothing", () => ((SoloSongSelect)Game.ScreenStack.CurrentScreen).ChildrenOfType().Single().Search("fdsajkl;fgewq")); AddUntilStep("wait for no results", () => Beatmap.IsDefault); var firstImport = importScore(1, new CatchRuleset().RulesetInfo); @@ -109,7 +110,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelectWithConvertRulesetChange([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); AddStep("set convert to false", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); @@ -121,7 +122,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelect([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); var firstImport = importScore(1); presentAndConfirm(firstImport, type); @@ -134,7 +135,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestFromSongSelectDifferentRuleset([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); var firstImport = importScore(1); presentAndConfirm(firstImport, type); @@ -147,7 +148,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestPresentTwoImportsWithSameOnlineIDButDifferentHashes([Values] ScorePresentType type) { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); var firstImport = importScore(1); presentAndConfirm(firstImport, type); @@ -160,7 +161,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestScoreRefetchIgnoresEmptyHash() { AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented); importScore(-1, hash: string.Empty); importScore(3, hash: @"deadbeef"); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenFooterNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenFooterNavigation.cs new file mode 100644 index 0000000000..0b17b66dec --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenFooterNavigation.cs @@ -0,0 +1,200 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Overlays; +using osu.Game.Screens; +using osu.Game.Screens.Footer; + +namespace osu.Game.Tests.Visual.Navigation +{ + public partial class TestSceneScreenFooterNavigation : OsuGameTestScene + { + private ScreenFooter screenFooter => this.ChildrenOfType().Single(); + + [Test] + public void TestFooterButtonsOnScreenTransitions() + { + PushAndConfirm(() => new TestScreenOne()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + + PushAndConfirm(() => new TestScreenTwo()); + AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); + + AddStep("exit screen", () => Game.ScreenStack.Exit()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + } + + [Test] + public void TestFooterHidesOldBackButton() + { + PushAndConfirm(() => new TestScreen(false)); + AddAssert("footer hidden", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("old back button shown", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Visible)); + + PushAndConfirm(() => new TestScreen(true)); + AddAssert("footer shown", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddAssert("old back button hidden", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + PushAndConfirm(() => new TestScreen(false)); + AddAssert("footer hidden", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("back button shown", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("exit screen", () => Game.ScreenStack.Exit()); + AddAssert("footer shown", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddAssert("old back button hidden", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("exit screen", () => Game.ScreenStack.Exit()); + AddAssert("footer hidden", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("old back button shown", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Visible)); + } + + /// + /// Tests pushing and exiting subscreens that have footers. + /// + [Test] + public void TestPushAndExitSubScreens() + { + TestScreenWithSubScreen screen = null!; + + PushAndConfirm(() => screen = new TestScreenWithSubScreen()); + AddAssert("footer hidden", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("old back button shown", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Visible)); + + pushSubScreenAndConfirm(() => screen, () => new TestScreenOne()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + + pushSubScreenAndConfirm(() => screen, () => new TestScreenTwo()); + AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); + + AddStep("exit sub screen", () => screen.ExitSubScreen()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + + AddStep("exit sub screen", () => screen.ExitSubScreen()); + AddAssert("footer hidden", () => screenFooter.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("old back button shown", () => Game.BackButton.State.Value, () => Is.EqualTo(Visibility.Visible)); + } + + /// + /// Tests pushing a new parenting screen while the footer is displayed from a subscreen. + /// + [Test] + public void TestPushParentScreenDuringSubScreen() + { + TestScreenWithSubScreen screen = null!; + + PushAndConfirm(() => screen = new TestScreenWithSubScreen()); + pushSubScreenAndConfirm(() => screen, () => new TestScreenOne()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + + PushAndConfirm(() => new TestScreenTwo()); + AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); + + AddStep("exit parent screen", () => Game.ScreenStack.Exit()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + } + + /// + /// Tests pushing a new subscreen after a new parenting screen has been pushed. + /// + [Test] + public void TestPushSubScreenWhileNotCurrent() + { + TestScreenWithSubScreen screen = null!; + + PushAndConfirm(() => screen = new TestScreenWithSubScreen()); + pushSubScreenAndConfirm(() => screen, () => new TestScreenOne()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + + PushAndConfirm(() => new TestScreenOne()); + AddUntilStep("button one shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + + // Can't use the helper method because the screen never loads + AddStep("Push new sub screen", () => screen.PushSubScreen(new TestScreenTwo())); + AddWaitStep("wait for potential screen load", 5); + AddUntilStep("button one still shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button One")); + + AddStep("exit parent screen", () => Game.ScreenStack.Exit()); + AddUntilStep("button two shown", () => screenFooter.ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("Button Two")); + } + + private void pushSubScreenAndConfirm(Func target, Func newScreen) + { + Screen screen = null!; + IScreen? previousScreen = null; + + AddStep("Push new sub screen", () => + { + previousScreen = target().CurrentSubScreen; + target().PushSubScreen(screen = newScreen()); + }); + + AddUntilStep("Wait for new screen", () => screen.IsLoaded + && target().CurrentSubScreen != previousScreen + && (previousScreen == null || previousScreen.GetChildScreen() == screen)); + } + + private partial class TestScreenOne : OsuScreen + { + public override bool ShowFooter => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + public override IReadOnlyList CreateFooterButtons() => new[] + { + new ScreenFooterButton { Text = "Button One" }, + }; + } + + private partial class TestScreenTwo : OsuScreen + { + public override bool ShowFooter => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + public override IReadOnlyList CreateFooterButtons() => new[] + { + new ScreenFooterButton { Text = "Button Two" }, + }; + } + + private partial class TestScreen : OsuScreen + { + public override bool ShowFooter { get; } + + public TestScreen(bool footer) + { + ShowFooter = footer; + } + } + + private partial class TestScreenWithSubScreen : OsuScreen, IHasSubScreenStack + { + public ScreenStack SubScreenStack { get; } + + public TestScreenWithSubScreen() + { + InternalChild = SubScreenStack = new ScreenStack + { + RelativeSizeAxes = Axes.Both + }; + } + + public IScreen? CurrentSubScreen => SubScreenStack.CurrentScreen; + + public void PushSubScreen(IScreen screen) => SubScreenStack.Push(screen); + + public void ExitSubScreen() => SubScreenStack.Exit(); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 5646649d33..8a0c9f561c 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -11,6 +11,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -21,10 +22,10 @@ using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Extensions; +using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Online.Leaderboards; using osu.Game.Online.Notifications.WebSocket; using osu.Game.Online.Notifications.WebSocket.Events; using osu.Game.Overlays; @@ -32,6 +33,10 @@ using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mania.Configuration; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; @@ -41,16 +46,15 @@ using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Leaderboards; -using osu.Game.Screens.Select.Options; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Resources; using osu.Game.Utils; using osuTK; using osuTK.Input; -using SharpCompress; namespace osu.Game.Tests.Visual.Navigation { @@ -71,6 +75,7 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); PushAndConfirm(() => playlistScreen = new Screens.OnlinePlay.Playlists.Playlists()); + AddUntilStep("wait for lounge", () => (playlistScreen.CurrentSubScreen as LoungeSubScreen)?.IsLoaded == true); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -134,62 +139,70 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithEscape() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; + ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); - AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddStep("Show mods overlay", () => + { + modSelect = songSelect!.ChildrenOfType().Single(); + modSelect.Show(); + }); + AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); pushEscape(); - AddAssert("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + AddAssert("Overlay was hidden", () => modSelect.State.Value == Visibility.Hidden); exitViaEscapeAndConfirm(); } [Test] public void TestEnterGameplayWhileFilteringToNoSelection() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - AddStep("force selection", () => + AddStep("force selection and change filter immediately", () => { - songSelect.FinaliseSelection(); - songSelect.FilterControl.CurrentTextSearch.Value = "test"; + InputManager.Key(Key.Enter); + songSelect.ChildrenOfType().Single().Search("test"); }); AddUntilStep("wait for player", () => !songSelect.IsCurrentScreen()); AddStep("return to song select", () => songSelect.MakeCurrent()); - AddUntilStep("wait for selection lost", () => songSelect.Beatmap.IsDefault); + AddUntilStep("selection not lost", () => !songSelect.Beatmap.IsDefault); + AddUntilStep("placeholder visible", () => songSelect.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); } [Test] public void TestSongSelectBackActionHandling() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + + AddUntilStep("wait for filter control", () => filterControlTextBox().IsLoaded); AddStep("set filter", () => filterControlTextBox().Current.Value = "test"); AddStep("press back", () => InputManager.Click(MouseButton.Button1)); - AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect); + AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen, () => Is.EqualTo(songSelect)); AddAssert("filter cleared", () => string.IsNullOrEmpty(filterControlTextBox().Current.Value)); AddStep("set filter again", () => filterControlTextBox().Current.Value = "test"); AddStep("open collections dropdown", () => { - InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); + InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("press back once", () => InputManager.Click(MouseButton.Button1)); AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect); AddAssert("collections dropdown closed", () => songSelect - .ChildrenOfType().Single() + .ChildrenOfType().Single() .ChildrenOfType.DropdownMenu>().Single().State == MenuState.Closed); AddStep("press back a second time", () => InputManager.Click(MouseButton.Button1)); @@ -198,26 +211,77 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back a third time", () => InputManager.Click(MouseButton.Button1)); ConfirmAtMainMenu(); - TextBox filterControlTextBox() => songSelect.ChildrenOfType().Single(); + FilterControl.SongSelectSearchTextBox filterControlTextBox() => songSelect.ChildrenOfType().Single(); + } + + [Test] + public void TestSongSelectRandomRewindButton() + { + Guid? originalSelection = null; + SoloSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); + + AddStep("Add two beatmaps", () => + { + Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8)); + Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8)); + }); + + AddUntilStep("wait for selected", () => + { + originalSelection = Game.Beatmap.Value.BeatmapInfo.ID; + return !Game.Beatmap.IsDefault; + }); + + AddStep("hit random", () => + { + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("wait for selection changed", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.Not.EqualTo(originalSelection)); + + AddStep("hit random rewind", () => InputManager.Click(MouseButton.Right)); + AddUntilStep("wait for selection reverted", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.EqualTo(originalSelection)); } [Test] public void TestSongSelectScrollHandling() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; double scrollPosition = 0; AddStep("set game volume to max", () => Game.Dependencies.Get().SetValue(FrameworkSetting.VolumeUniversal, 1d)); AddUntilStep("wait for volume overlay to hide", () => Game.ChildrenOfType().SingleOrDefault()?.State.Value, () => Is.EqualTo(Visibility.Hidden)); - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.IsLoaded); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + AddUntilStep("wait for beatmap", () => Game.ChildrenOfType().Any()); + + // TODO: this logic can likely be removed when we fix https://github.com/ppy/osu/issues/33379 + // It should be probably be immediate in this case. + AddWaitStep("wait for scroll", 10); AddStep("store scroll position", () => scrollPosition = getCarouselScrollPosition()); - AddStep("move to left side", () => InputManager.MoveMouseTo( - songSelect.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + new Vector2(1))); + AddStep("move to title wedge", () => InputManager.MoveMouseTo( + songSelect.ChildrenOfType().Single())); + AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); + AddAssert("carousel didn't move", getCarouselScrollPosition, () => Is.EqualTo(scrollPosition)); + + AddRepeatStep("alt-scroll down", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.ScrollVerticalBy(-1); + InputManager.ReleaseKey(Key.AltLeft); + }, 5); + AddAssert("game volume decreased", () => Game.Dependencies.Get().Get(FrameworkSetting.VolumeUniversal), () => Is.LessThan(1)); + + AddStep("set game volume to max", () => Game.Dependencies.Get().SetValue(FrameworkSetting.VolumeUniversal, 1d)); + + AddStep("move to details area", () => InputManager.MoveMouseTo( + songSelect.ChildrenOfType().Single())); AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); AddAssert("carousel didn't move", getCarouselScrollPosition, () => Is.EqualTo(scrollPosition)); @@ -233,7 +297,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); AddAssert("carousel moved", getCarouselScrollPosition, () => Is.Not.EqualTo(scrollPosition)); - double getCarouselScrollPosition() => Game.ChildrenOfType>().Single().Current; + double getCarouselScrollPosition() => Game.ChildrenOfType>().Single().ChildrenOfType().Single().Current; } /// @@ -243,21 +307,21 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestOpenModSelectOverlayUsingAction() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); AddStep("Show mods overlay", () => InputManager.Key(Key.F1)); - AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + AddAssert("Overlay was shown", () => songSelect!.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] public void TestAttemptPlayBeatmapWrongHashFails() { - Screens.Select.SongSelect songSelect = null; + Screens.SelectV2.SongSelect songSelect = null; AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -288,11 +352,11 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestAttemptPlayBeatmapMissingFails() { - Screens.Select.SongSelect songSelect = null; + Screens.SelectV2.SongSelect songSelect = null; AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -317,14 +381,154 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for song select", () => songSelect.IsCurrentScreen()); } + [Test] + public void TestOffsetAdjustDuringPause() + { + Player player = null; + + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail() }); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + player = Game.ScreenStack.CurrentScreen as Player; + return player?.IsLoaded == true; + }); + + AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning); + checkOffset(0); + + AddStep("adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + AddStep("pause", () => player.ChildrenOfType().First().Stop()); + AddUntilStep("wait for pause", () => player.ChildrenOfType().First().IsPaused.Value, () => Is.True); + AddStep("attempt adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + void checkOffset(double offset) + { + AddUntilStep($"control offset is {offset}", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Current.Value, + () => Is.EqualTo(offset)); + AddUntilStep($"database offset is {offset}", () => Game.BeatmapManager.QueryBeatmap(b => b.ID == Game.Beatmap.Value.BeatmapInfo.ID)!.UserSettings.Offset, + () => Is.EqualTo(offset)); + } + } + + [Test] + public void TestScrollSpeedAdjustDuringGameplay() + { + Player player = null; + + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("switch to mania ruleset", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.Number4); + InputManager.ReleaseKey(Key.LControl); + }); + + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail() }); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + player = Game.ScreenStack.CurrentScreen as Player; + return player?.IsLoaded == true; + }); + + AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning); + checkScrollSpeed(8, 8); + + AddStep("adjust scroll speed via keyboard", () => InputManager.Key(Key.F4)); + checkScrollSpeed(9, 9); + + AddStep("seek beyond 10 seconds", () => player.ChildrenOfType().First().Seek(10500)); + AddUntilStep("wait for seek", () => player.ChildrenOfType().First().CurrentTime, () => Is.GreaterThan(10600)); + AddStep("attempt adjust offset via keyboard", () => InputManager.Key(Key.F4)); + checkScrollSpeed(9, 9); + + AddStep("attempt adjust offset via config change", () => getConfigManager().SetValue(ManiaRulesetSetting.ScrollSpeed, 10.0)); + checkScrollSpeed(10, 9); + + void checkScrollSpeed(double configValue, double gameplayValue) + { + AddUntilStep($"config value is {configValue}", () => getConfigManager().Get(ManiaRulesetSetting.ScrollSpeed), () => Is.EqualTo(configValue)); + AddUntilStep($"gameplay value is {gameplayValue}", () => this.ChildrenOfType().Single().TargetTimeRange, + () => Is.EqualTo(DrawableManiaRuleset.ComputeScrollTime(gameplayValue))); + } + + ManiaRulesetConfigManager getConfigManager() => ((ManiaRulesetConfigManager)Game.Dependencies.Get().GetConfigFor(new ManiaRuleset())!); + } + + [Test] + public void TestOffsetAdjustDuringGameplay() + { + Player player = null; + + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail() }); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + player = Game.ScreenStack.CurrentScreen as Player; + return player?.IsLoaded == true; + }); + + AddUntilStep("wait for track playing", () => Game.Beatmap.Value.Track.IsRunning); + checkOffset(0); + + AddStep("adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + AddStep("seek beyond 10 seconds", () => player.ChildrenOfType().First().Seek(10500)); + AddUntilStep("wait for seek", () => player.ChildrenOfType().First().CurrentTime, () => Is.GreaterThan(10600)); + AddStep("attempt adjust offset via keyboard", () => InputManager.Key(Key.Minus)); + checkOffset(-1); + + void checkOffset(double offset) + { + AddUntilStep($"control offset is {offset}", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Current.Value, + () => Is.EqualTo(offset)); + AddUntilStep($"database offset is {offset}", () => Game.BeatmapManager.QueryBeatmap(b => b.ID == Game.Beatmap.Value.BeatmapInfo.ID)!.UserSettings.Offset, + () => Is.EqualTo(offset)); + } + } + [Test] public void TestRetryCountIncrements() { Player player = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -355,18 +559,18 @@ namespace osu.Game.Tests.Visual.Navigation } [Test] - public void TestLastScoreNullAfterExitingPlayer() + public void TestLastScoreNotNullAfterExitingPlayer() { - AddUntilStep("wait for last play null", getLastPlay, () => Is.Null); + AddUntilStep("last play null", getLastPlay, () => Is.Null); var getOriginalPlayer = playToCompletion(); AddStep("attempt to retry", () => getOriginalPlayer().ChildrenOfType().First().Action()); - AddUntilStep("wait for last play matches player", getLastPlay, () => Is.EqualTo(getOriginalPlayer().Score.ScoreInfo)); + AddUntilStep("last play matches player", getLastPlay, () => Is.EqualTo(getOriginalPlayer().Score.ScoreInfo)); AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != getOriginalPlayer() && Game.ScreenStack.CurrentScreen is Player); AddStep("exit player", () => (Game.ScreenStack.CurrentScreen as Player)?.Exit()); - AddUntilStep("wait for last play null", getLastPlay, () => Is.Null); + AddUntilStep("last play not null", getLastPlay, () => Is.Not.Null); ScoreInfo getLastPlay() => Game.Dependencies.Get().Get(Static.LastLocalUserScore); } @@ -427,27 +631,20 @@ namespace osu.Game.Tests.Visual.Navigation playToResults(); ScoreInfo score = null; - LeaderboardScore scorePanel = null; + BeatmapLeaderboardScore scorePanel = null; AddStep("get score", () => score = ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score); AddAssert("ensure score is databased", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == false)); - AddStep("press back button", () => Game.ChildrenOfType().First().Action()); + AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); AddStep("show local scores", - () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + () => Game.ChildrenOfType>().First().Current.Value = BeatmapLeaderboardScope.Local); - AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); + AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); - AddStep("open options", () => InputManager.Key(Key.F3)); - - AddStep("choose clear all scores", () => InputManager.Key(Key.Number4)); - - AddUntilStep("wait for dialog display", () => ((Drawable)Game.Dependencies.Get()).IsLoaded); - AddUntilStep("wait for dialog", () => Game.Dependencies.Get().CurrentDialog != null); - AddStep("confirm deletion", () => InputManager.Key(Key.Number1)); - AddUntilStep("wait for dialog dismissed", () => Game.Dependencies.Get().CurrentDialog == null); + AddStep("Clear all scores", () => Game.Dependencies.Get().Delete()); AddUntilStep("ensure score is pending deletion", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == true)); @@ -460,18 +657,18 @@ namespace osu.Game.Tests.Visual.Navigation playToResults(); ScoreInfo score = null; - LeaderboardScore scorePanel = null; + BeatmapLeaderboardScore scorePanel = null; AddStep("get score", () => score = ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score); AddAssert("ensure score is databased", () => Game.Realm.Run(r => r.Find(score.ID)?.DeletePending == false)); - AddStep("press back button", () => Game.ChildrenOfType().First().Action()); + AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); AddStep("show local scores", - () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + () => Game.ChildrenOfType>().First().Current.Value = BeatmapLeaderboardScope.Local); - AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); + AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); AddStep("right click panel", () => { @@ -482,8 +679,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("click delete", () => { var dropdownItem = Game - .ChildrenOfType().First() - .ChildrenOfType().First() + .ChildrenOfType().First() .ChildrenOfType().First(i => i.Item.Text.ToString() == "Delete"); InputManager.MoveMouseTo(dropdownItem); @@ -500,50 +696,12 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for score panel removal", () => scorePanel.Parent == null); } - [TestCase(true)] - [TestCase(false)] - public void TestSongContinuesAfterExitPlayer(bool withUserPause) - { - Player player = null; - - IWorkingBeatmap beatmap() => Game.Beatmap.Value; - - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); - - AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); - - AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - - if (withUserPause) - AddStep("pause", () => Game.Dependencies.Get().Stop(true)); - - AddStep("press enter", () => InputManager.Key(Key.Enter)); - - AddUntilStep("wait for player", () => - { - DismissAnyNotifications(); - return (player = Game.ScreenStack.CurrentScreen as Player) != null; - }); - - AddUntilStep("wait for fail", () => player.GameplayState.HasFailed); - - AddUntilStep("wait for track stop", () => !Game.MusicController.IsPlaying); - AddAssert("Ensure time before preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime); - - pushEscape(); - - AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); - AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime); - } - [Test] public void TestMenuMakesMusic() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); AddUntilStep("wait for no track", () => Game.MusicController.CurrentTrack.IsDummyDevice); @@ -555,26 +713,32 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPushSongSelectAndPressBackButtonImmediately() { - AddStep("push song select", () => Game.ScreenStack.Push(new TestPlaySongSelect())); - AddStep("press back button", () => Game.ChildrenOfType().First().Action()); - AddWaitStep("wait two frames", 2); + AddStep("push song select", () => Game.ScreenStack.Push(new SoloSongSelect())); + AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); + + ConfirmAtMainMenu(); } [Test] public void TestExitSongSelectWithClick() { - TestPlaySongSelect songSelect = null; + SoloSongSelect songSelect = null; + ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); - AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddStep("Show mods overlay", () => + { + modSelect = songSelect!.ChildrenOfType().Single(); + modSelect.Show(); + }); + AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); AddStep("Move mouse to dimmed area", () => InputManager.MoveMouseTo(new Vector2( songSelect.ScreenSpaceDrawQuad.TopLeft.X + 1, songSelect.ScreenSpaceDrawQuad.TopLeft.Y + songSelect.ScreenSpaceDrawQuad.Height / 2))); AddStep("Click left mouse button", () => InputManager.Click(MouseButton.Left)); - AddUntilStep("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + AddUntilStep("Overlay was hidden", () => modSelect.State.Value == Visibility.Hidden); exitViaBackButtonAndConfirm(); } @@ -639,10 +803,18 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); - TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + SoloSongSelect songSelect = null; + ModSelectOverlay modSelect = null; - AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddStep("Show mods overlay", () => + { + modSelect = songSelect!.ChildrenOfType().Single(); + modSelect.Show(); + }); + AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); + + AddStep("Show mods overlay", () => modSelect.Show()); AddStep("Change ruleset to osu!taiko", () => { @@ -653,7 +825,7 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Ruleset changed to osu!taiko", () => Game.Toolbar.ChildrenOfType().Single().Current.Value.OnlineID == 1); - AddAssert("Mods overlay still visible", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + AddAssert("Mods overlay still visible", () => modSelect.State.Value == Visibility.Visible); } [Test] @@ -663,10 +835,12 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + SoloSongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); - AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show()); + AddStep("Show options overlay", () => InputManager.Key(Key.F3)); + AddUntilStep("Options overlay visible", () => this.ChildrenOfType().SingleOrDefault()?.State.Value == Visibility.Visible); AddStep("Change ruleset to osu!taiko", () => { @@ -677,7 +851,7 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Ruleset changed to osu!taiko", () => Game.Toolbar.ChildrenOfType().Single().Current.Value.OnlineID == 1); - AddAssert("Options overlay still visible", () => songSelect.BeatmapOptionsOverlay.State.Value == Visibility.Visible); + AddAssert("Options overlay still visible", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] @@ -736,7 +910,7 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for lounge", () => multiplayerComponents.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open()); - AddStep("press back button", () => Game.ChildrenOfType().First().Action()); + AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); AddWaitStep("wait two frames", 2); AddStep("exit lounge", () => Game.ScreenStack.Exit()); @@ -949,7 +1123,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitGameFromSongSelect() { - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); exitViaEscapeAndConfirm(); pushEscape(); // returns to osu! logo @@ -964,6 +1138,8 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitWithHoldDisabled() { + AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); + AddStep("set hold delay to 0", () => Game.LocalConfig.SetValue(OsuSetting.UIHoldActivationDelay, 0.0)); AddStep("press escape twice rapidly", () => @@ -1019,10 +1195,10 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("close settings sidebar", () => InputManager.Key(Key.Escape)); - Screens.Select.SongSelect songSelect = null; + Screens.SelectV2.SongSelect songSelect = null; AddRepeatStep("go to solo", () => InputManager.Key(Key.P), 3); - AddUntilStep("wait for song select", () => (songSelect = Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect) != null); - AddUntilStep("wait for beatmap sets loaded", () => songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => (songSelect = Game.ScreenStack.CurrentScreen as Screens.SelectV2.SongSelect) != null); + AddUntilStep("wait for beatmap sets loaded", () => songSelect.CarouselItemsPresented); AddStep("switch to osu! ruleset", () => { @@ -1032,7 +1208,7 @@ namespace osu.Game.Tests.Visual.Navigation }); AddStep("touch beatmap wedge", () => { - var wedge = Game.ChildrenOfType().Single(); + var wedge = Game.ChildrenOfType().Single(); var touch = new Touch(TouchSource.Touch2, wedge.ScreenSpaceDrawQuad.Centre); InputManager.BeginTouch(touch); InputManager.EndTouch(touch); @@ -1048,7 +1224,7 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf()); AddStep("touch beatmap wedge", () => { - var wedge = Game.ChildrenOfType().Single(); + var wedge = Game.ChildrenOfType().Single(); var touch = new Touch(TouchSource.Touch2, wedge.ScreenSpaceDrawQuad.Centre); InputManager.BeginTouch(touch); InputManager.EndTouch(touch); @@ -1065,7 +1241,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("click beatmap wedge", () => { - InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddUntilStep("touch device mod not activated", () => Game.SelectedMods.Value, () => Has.None.InstanceOf()); @@ -1076,7 +1252,7 @@ namespace osu.Game.Tests.Visual.Navigation { BeatmapSetInfo beatmapSet = null; - PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("import beatmap", () => beatmapSet = BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); AddUntilStep("wait for selected", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet)); AddStep("select", () => InputManager.Key(Key.Enter)); @@ -1106,9 +1282,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectAndImmediatelyClickLogo() { - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -1137,9 +1313,9 @@ namespace osu.Game.Tests.Visual.Navigation { BeatmapSetInfo beatmap = null; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -1168,9 +1344,9 @@ namespace osu.Game.Tests.Visual.Navigation IWorkingBeatmap beatmap() => Game.Beatmap.Value; - Screens.Select.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + Screens.SelectV2.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); @@ -1208,12 +1384,5 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("Click back button", () => InputManager.Click(MouseButton.Left)); ConfirmAtMainMenu(); } - - public partial class TestPlaySongSelect : PlaySongSelect - { - public ModSelectOverlay ModSelectOverlay => ModSelect; - - public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; - } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 5267a57a05..0e1fa63439 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -5,6 +5,7 @@ using System; using System.Linq; +using Newtonsoft.Json; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -16,6 +17,7 @@ using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Online.API; using osu.Game.Beatmaps; +using osu.Game.Overlays.Mods; using osu.Game.Overlays.Settings; using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets.Mods; @@ -23,18 +25,21 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Screens.SelectV2; using osu.Game.Skinning; using osu.Game.Tests.Beatmaps.IO; using osuTK; using osuTK.Input; -using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { public partial class TestSceneSkinEditorNavigation : OsuGameTestScene { - private TestPlaySongSelect songSelect; + private SoloSongSelect songSelect; + private ModSelectOverlay modSelect => songSelect.ChildrenOfType().First(); + private SkinEditor skinEditor => Game.ChildrenOfType().FirstOrDefault(); [Test] @@ -101,6 +106,67 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get().CurrentSkin.Value.SkinInfo.Value.Protected); } + [Test] + public void TestMutateProtectedSkinFromMainMenu_UndoToInitialStateIsCorrect() + { + AddStep("set default skin", () => Game.Dependencies.Get().CurrentSkinInfo.SetDefault()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + openSkinEditor(); + AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get().CurrentSkin.Value.SkinInfo.Value.Protected); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + + string state = string.Empty; + + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.Position != new Vector2())); + AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); + AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); + AddStep("undo", () => InputManager.Keys(PlatformAction.Undo)); + AddUntilStep("only one accuracy meter left", + () => Game.ChildrenOfType().Single().ChildrenOfType().Count(), + () => Is.EqualTo(1)); + AddAssert("accuracy meter state unchanged", + () => JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo()), + () => Is.EqualTo(state)); + } + + [Test] + public void TestMutateProtectedSkinFromPlayer_UndoToInitialStateIsCorrect() + { + AddStep("set default skin", () => Game.Dependencies.Get().CurrentSkinInfo.SetDefault()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + advanceToSongSelect(); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("enable NF", () => Game.SelectedMods.Value = new[] { new OsuModNoFail() }); + AddStep("enter gameplay", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + openSkinEditor(); + + string state = string.Empty; + + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.Position != new Vector2())); + AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); + AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); + AddStep("undo", () => InputManager.Keys(PlatformAction.Undo)); + AddUntilStep("only one accuracy meter left", + () => Game.ChildrenOfType().Single().ChildrenOfType().Count(), + () => Is.EqualTo(1)); + AddAssert("accuracy meter state unchanged", + () => JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo()), + () => Is.EqualTo(state)); + } + [Test] public void TestComponentsDeselectedOnSkinEditorHide() { @@ -114,12 +180,7 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for components", () => skinEditor.ChildrenOfType().Any()); - AddStep("select all components", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.Key(Key.A); - InputManager.ReleaseKey(Key.ControlLeft); - }); + AddStep("select all components", () => InputManager.Keys(PlatformAction.SelectAll)); AddUntilStep("components selected", () => skinEditor.SelectedComponents.Count > 0); @@ -212,6 +273,36 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("no mod selected", () => !((Player)Game.ScreenStack.CurrentScreen).Mods.Value.Any()); } + [Test] + public void TestGameplaySettingsDoesNotExpandWhenSkinOverlayPresent() + { + advanceToSongSelect(); + openSkinEditor(); + AddUntilStep("skin editor visible", () => skinEditor.State.Value == Visibility.Visible); + + AddStep("select autoplay", () => Game.SelectedMods.Value = new Mod[] { new OsuModAutoplay() }); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); + + AddUntilStep("wait for settings", () => getPlayerSettingsOverlay() != null); + AddAssert("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0)); + + AddStep("move cursor to right of screen", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight)); + AddAssert("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0)); + + toggleSkinEditor(); + AddUntilStep("skin editor hidden", () => skinEditor.State.Value == Visibility.Hidden); + + AddStep("move cursor slightly", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(2))); + AddUntilStep("settings visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.GreaterThan(0)); + + AddStep("move cursor to right of screen too far", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(10240, 0))); + AddUntilStep("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0)); + + PlayerSettingsOverlay getPlayerSettingsOverlay() => ((Player)Game.ScreenStack.CurrentScreen).ChildrenOfType().SingleOrDefault(); + } + [Test] public void TestCinemaModRemovedOnEnteringGameplay() { @@ -231,10 +322,10 @@ namespace osu.Game.Tests.Visual.Navigation public void TestModOverlayClosesOnOpeningSkinEditor() { advanceToSongSelect(); - AddStep("open mod overlay", () => songSelect.ModSelectOverlay.Show()); + AddStep("open mod overlay", () => modSelect.Show()); openSkinEditor(); - AddUntilStep("mod overlay closed", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + AddUntilStep("mod overlay closed", () => modSelect.State.Value == Visibility.Hidden); } [Test] @@ -348,8 +439,8 @@ namespace osu.Game.Tests.Visual.Navigation private void advanceToSongSelect() { - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); } private void openSkinEditor() diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs new file mode 100644 index 0000000000..4295e9e88e --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs @@ -0,0 +1,349 @@ +// Copyright (c) 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.Extensions; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Resources; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Navigation +{ + /// + /// Tests copied out of `TestSceneScreenNavigation` which are specific to song select. + /// These are for SongSelectV2. Eventually, the tests in the above class should be deleted along with old song select. + /// + public partial class TestSceneSongSelectNavigation : OsuGameTestScene + { + [Test] + public void TestRetryFromResults() + { + var getOriginalPlayer = playToResults(); + + AddStep("attempt to retry", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).ChildrenOfType().First().Action()); + AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != getOriginalPlayer() && Game.ScreenStack.CurrentScreen is Player); + } + + [Test] + public void TestPushSongSelectAndClickBottomLeftCorner() + { + AddStep("push song select", () => Game.ScreenStack.Push(new SoloSongSelect())); + + // TODO: without this step, a critical bug will be hit, see inline comment in `OsuGame.handleBackButton`. + AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect select && select.IsLoaded); + + AddStep("click in corner", () => + { + InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.BottomLeft); + InputManager.Click(MouseButton.Left); + }); + + ConfirmAtMainMenu(); + } + + [Test] + public void TestPushSongSelectAndPressBackButtonImmediately() + { + AddStep("push song select", () => Game.ScreenStack.Push(new SoloSongSelect())); + + // TODO: without this step, a critical bug will be hit, see inline comment in `OsuGame.handleBackButton`. + AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect select && select.IsLoaded); + + AddStep("press back button", () => Game.ChildrenOfType().First().Action!.Invoke()); + + ConfirmAtMainMenu(); + } + + [Test] + public void TestEditBeatmap() + { + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("open menu", () => InputManager.Key(Key.F3)); + AddStep("trigger edit", () => + { + // TODO: should be 5, not 4. + InputManager.Key(Key.Number4); + }); + + waitForScreen(); + + pushEscape(); + waitForScreen(); + } + + [Test] + public void TestPresentBeatmapFromMainMenuUsesPreviewPoint() + { + BeatmapSetInfo beatmapInfo = null!; + + AddStep("import beatmap", () => + { + var task = BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true); + task.WaitSafely(); + beatmapInfo = task.GetResultSafely(); + }); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapInfo)); + + AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); + + AddAssert("ensure time is reset to preview point", + () => + { + double timeFromPreviewPoint = Math.Abs(Game.MusicController.CurrentTrack.CurrentTime - beatmapInfo.Metadata.PreviewTime); + return timeFromPreviewPoint < 5000; + }); + } + + [TestCase(true)] + [TestCase(false)] + public void TestSongContinuesAfterExitPlayer(bool withUserPause) + { + Player? player = null; + + IWorkingBeatmap beatmap() => Game.Beatmap.Value; + + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + if (withUserPause) + AddStep("pause", () => Game.Dependencies.Get().Stop(true)); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return (player = Game.ScreenStack.CurrentScreen as Player) != null; + }); + + AddUntilStep("wait for fail", () => player?.GameplayState.HasFailed, () => Is.True); + + AddUntilStep("wait for track stop", () => !Game.MusicController.IsPlaying); + AddAssert("Ensure time before preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime); + + pushEscape(); + + AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); + AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime); + } + + [Test] + public void TestAutoplayShortcutReturnsInitialModsOnExit() + { + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); + + AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); + + AddStep("open mod select", () => InputManager.Key(Key.F1)); + AddStep("search magnetised", () => this.ChildrenOfType().Single().SearchTerm = "MG"); + AddStep("select", () => InputManager.Key(Key.Enter)); + + AddAssert("magnetised selected", () => Game.SelectedMods.Value.Single(), Is.TypeOf); + AddStep("configure mod", () => ((OsuModMagnetised)Game.SelectedMods.Value.Single()).AttractionStrength.Value = 1.0f); + + pushEscape(); + pushEscape(); + + AddStep("press ctrl+enter", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Enter); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + + AddAssert("only autoplay selected", () => Game.SelectedMods.Value.Single(), Is.TypeOf); + + pushEscape(); + waitForScreen(); + + AddAssert("magnetised selected", () => Game.SelectedMods.Value.Single(), Is.TypeOf); + AddAssert("mod configured", () => ((OsuModMagnetised)Game.SelectedMods.Value.Single()).AttractionStrength.Value, () => Is.EqualTo(1.0f)); + } + + [Test] + public void TestLeaderboardCorrectInPlayer() + { + IWorkingBeatmap beatmap() => Game.Beatmap.Value; + + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + AddStep("switch to next difficulty and immediately press enter", () => + { + InputManager.Key(Key.Down); + Schedule(() => InputManager.Key(Key.Enter)); + }); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + AddAssert("leaderboard matches gameplay beatmap", () => Game.ChildrenOfType().Single().CurrentCriteria?.Beatmap, () => Is.EqualTo(beatmap().BeatmapInfo)); + } + + [Test] + public void TestEnterKeyProgressesToGameplayEvenIfCarouselFilteredOut() + { + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("filter out active beatmap", () => this.ChildrenOfType().First().Text = "abacadabadaeba"); + AddUntilStep("wait for filter", () => this.ChildrenOfType().Single().IsFiltering, () => Is.False); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("player entered", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + } + + [Test] + public void TestSelectionNotLostWithConvertedBeatmapsShown() + { + BeatmapSetInfo beatmapSet = null!; + BeatmapInfo selectedBeatmap = null!; + + AddStep("import beatmap", () => beatmapSet = BeatmapImportHelper.LoadOszIntoOsu(Game).GetResultSafely()); + PushAndConfirm(() => new SoloSongSelect()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("change ruleset to taiko", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Number2); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddStep("show converts", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + AddStep("select osu! beatmap", () => + { + selectedBeatmap = beatmapSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0); + Game.Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(selectedBeatmap); + }); + + pushEscape(); + AddUntilStep("went back to main menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + PushAndConfirm(() => new SoloSongSelect()); + + AddUntilStep("selected beatmap is still osu! ruleset", () => Game.Beatmap.Value.BeatmapInfo, () => Is.EqualTo(selectedBeatmap)); + } + + /// + /// Note: This test was written to demonstrate the failure described at https://github.com/ppy/osu/issues/35023, + /// but because the failure scenario there entailed a race condition, it was possible for the test to pass regardless + /// unless was increased. + /// + [Test] + public void TestPresentFromResults() + { + BeatmapSetInfo beatmapToPresent = null!; + BeatmapSetInfo beatmapToPlay = null!; + AddStep("manually insert beatmap to be presented", () => + { + Game.Realm.Write(r => + { + var beatmapSet = TestResources.CreateTestBeatmapSetInfo(3, [r.Find("osu")]); + r.Add(beatmapSet); + beatmapToPresent = beatmapSet.Detach(); + }); + }); + AddStep("import beatmap", () => beatmapToPlay = BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); + AddStep("set global beatmap", () => Game.Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(beatmapToPlay.Beatmaps.First())); + playToResults(); + AddStep("present beatmap from results", () => Game.PresentBeatmap(beatmapToPresent)); + AddUntilStep("back at song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect); + AddUntilStep("presented beatmap is current", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapToPresent)); + } + + private Func playToResults() + { + var player = playToCompletion(); + AddUntilStep("wait for results", () => (Game.ScreenStack.CurrentScreen as ResultsScreen)?.IsLoaded == true); + return player; + } + + private Func playToCompletion() + { + Player? player = null; + + IWorkingBeatmap beatmap() => Game.Beatmap.Value; + + PushAndConfirm(() => new SoloSongSelect()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail(), new OsuModDoubleTime { SpeedChange = { Value = 2 } } }); + + pushEnter(); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + 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 complete", () => player?.GameplayState.HasPassed, () => Is.True); + + return () => player!; + } + + private void waitForScreen() where T : OsuScreen => + AddUntilStep($"Wait for {typeof(T).ReadableName()}", () => Game.ScreenStack.CurrentScreen is T screen && screen.IsLoaded); + + private void pushEnter() => + AddStep("Press enter", () => InputManager.Key(Key.Enter)); + + private void pushEscape() => + AddStep("Press escape", () => InputManager.Key(Key.Escape)); + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 325cb9e0cb..b164c530cb 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -10,14 +10,16 @@ using osu.Game.Rulesets; using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using osu.Framework.Testing; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet.Scores; -using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Select.Details; @@ -99,8 +101,35 @@ namespace osu.Game.Tests.Visual.Online Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), }, + TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ], }, }, + RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ] }); }); @@ -166,7 +195,8 @@ namespace osu.Game.Tests.Visual.Online overlay.ShowBeatmapSet(set); }); - AddAssert("shown beatmaps of current ruleset", () => overlay.Header.HeaderContent.Picker.Difficulties.All(b => b.Beatmap.Ruleset.OnlineID == overlay.Header.RulesetSelector.Current.Value.OnlineID)); + AddAssert("shown beatmaps of current ruleset", + () => overlay.Header.HeaderContent.Picker.Difficulties.All(b => b.Beatmap.Ruleset.OnlineID == overlay.Header.RulesetSelector.Current.Value.OnlineID)); AddAssert("left-most beatmap selected", () => overlay.Header.HeaderContent.Picker.Difficulties.First().State == BeatmapPicker.DifficultySelectorState.Selected); } @@ -216,11 +246,35 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestBeatmapSetHasVideoOrStoryboard() + { + AddStep("show beatmapset with video", () => + { + var beatmapSet = getBeatmapSet(); + beatmapSet.HasVideo = true; + overlay.ShowBeatmapSet(beatmapSet); + }); + AddStep("show beatmapset with storyboard", () => + { + var beatmapSet = getBeatmapSet(); + beatmapSet.HasStoryboard = true; + overlay.ShowBeatmapSet(beatmapSet); + }); + AddStep("show beatmapset with video and storyboard", () => + { + var beatmapSet = getBeatmapSet(); + beatmapSet.HasVideo = true; + beatmapSet.HasStoryboard = true; + overlay.ShowBeatmapSet(beatmapSet); + }); + } + [Test] public void TestSelectedModsDontAffectStatistics() { AddStep("show map", () => overlay.ShowBeatmapSet(getBeatmapSet())); - AddAssert("AR displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == BeatmapsetsStrings.ShowStatsAr).Value, () => Is.EqualTo((0, 0))); + AddAssert("AR displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((0, 0))); AddStep("set AR10 diff adjust", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust @@ -228,7 +282,7 @@ namespace osu.Game.Tests.Visual.Online ApproachRate = { Value = 10 } } }); - AddAssert("AR still displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == BeatmapsetsStrings.ShowStatsAr).Value, () => Is.EqualTo((0, 0))); + AddAssert("AR still displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == SongSelectStrings.ApproachRate).Value, () => Is.EqualTo((0, 0))); } [Test] @@ -289,12 +343,70 @@ namespace osu.Game.Tests.Visual.Online { InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(0)); }); - AddAssert("guest mapper information not shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().All(s => s.Text != "BanchoBot")); + AddAssert("guest mapper information not shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().All(s => s.Text != "BanchoBot0")); AddStep("move mouse to guest difficulty", () => { InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); }); - AddAssert("guest mapper information shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().Any(s => s.Text == "BanchoBot")); + AddAssert("guest mapper information shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().Any(s => s.Text == "BanchoBot0")); + } + + [Test] + public void TestBeatmapsetWithALotGuestOwner() + { + AddStep("show map with 2 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(2))); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + AddStep("show map with 3 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(3))); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + AddStep("show map with 10 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(10))); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + AddStep("show map with 20 mapper", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty(20))); + AddStep("move mouse to guest difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(1)); + }); + } + + [Test] + public void TestBeatmapsetWithDeletedUser() + { + AddStep("show map with deleted user", () => + { + JObject jsonBlob = JObject.FromObject(getBeatmapSet(), new JsonSerializer + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }); + + jsonBlob["user"] = JToken.Parse( + """ + { + "avatar_url": null, + "country_code": null, + "default_group": "default", + "id": null, + "is_active": false, + "is_bot": false, + "is_deleted": true, + "is_online": false, + "is_supporter": false, + "last_visit": null, + "pm_friends_only": false, + "profile_colour": null, + "username": "[deleted user]" + } + """); + + overlay.ShowBeatmapSet(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(jsonBlob))); + }); } private APIBeatmapSet createManyDifficultiesBeatmapSet() @@ -336,22 +448,31 @@ namespace osu.Game.Tests.Visual.Online return beatmapSet; } - private APIBeatmapSet createBeatmapSetWithGuestDifficulty() + private APIBeatmapSet createBeatmapSetWithGuestDifficulty(int guestCount = 1) { var set = getBeatmapSet(); var beatmaps = new List(); + var beatmapOwners = new List(); + var ownersAPIUser = new List(); - var guestUser = new APIUser + for (int i = 0; i < guestCount; i++) { - Username = @"BanchoBot", - Id = 3, - }; + var guestUser = new APIUser + { + Username = @$"BanchoBot{i}", + Id = i + 3, + }; - set.RelatedUsers = new[] - { - set.Author, guestUser - }; + beatmapOwners.Add(new APIBeatmap.BeatmapOwner + { + Username = @$"BanchoBot{i}", + Id = i + 3, + }); + ownersAPIUser.Add(guestUser); + } + + set.RelatedUsers = new[] { set.Author }.Concat(ownersAPIUser).ToArray(); beatmaps.Add(new APIBeatmap { @@ -366,7 +487,7 @@ namespace osu.Game.Tests.Visual.Online Fails = Enumerable.Range(1, 100).Select(j => j % 12 - 6).ToArray(), Retries = Enumerable.Range(-2, 100).Select(j => j % 12 - 6).ToArray(), }, - Status = BeatmapOnlineStatus.Graveyard + Status = BeatmapOnlineStatus.Graveyard, }); beatmaps.Add(new APIBeatmap @@ -382,7 +503,8 @@ namespace osu.Game.Tests.Visual.Online Fails = Enumerable.Range(1, 100).Select(j => j % 12 - 6).ToArray(), Retries = Enumerable.Range(-2, 100).Select(j => j % 12 - 6).ToArray(), }, - Status = BeatmapOnlineStatus.Graveyard + Status = BeatmapOnlineStatus.Graveyard, + BeatmapOwners = beatmapOwners.ToArray(), }); set.Beatmaps = beatmaps.ToArray(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs index 040b903636..ee88bf917c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs @@ -107,7 +107,7 @@ namespace osu.Game.Tests.Visual.Online { Version = "2018.712.0", DisplayVersion = "2018.712.0", - UpdateStream = streams[OsuGameBase.CLIENT_STREAM_NAME], + UpdateStream = streams["lazer"], CreatedAt = new DateTime(2018, 7, 12), ChangelogEntries = new List { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs index 5f77e084da..364240502a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs @@ -115,6 +115,8 @@ namespace osu.Game.Tests.Visual.Online channelList.AddChannel(createRandomPrivateChannel()); }); + AddStep("Add Team Channel", () => channelList.AddChannel(createRandomTeamChannel())); + AddStep("Add Announce Channels", () => { for (int i = 0; i < 2; i++) @@ -189,5 +191,16 @@ namespace osu.Game.Tests.Visual.Online Id = id, }; } + + private Channel createRandomTeamChannel() + { + int id = TestResources.GetNextTestID(); + return new Channel + { + Name = $"Team {id}", + Type = ChannelType.Team, + Id = id, + }; + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index c793535255..e7337769fd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -75,9 +75,11 @@ namespace osu.Game.Tests.Visual.Online [TestCase("[Markdown link format with escaped [and \\[ paired] braces](https://dev.ppy.sh/home)", LinkAction.External)] [TestCase("(Old link format with escaped (and \\( paired) parentheses)[https://dev.ppy.sh/home] and [[also a rogue wiki link]]", LinkAction.External, LinkAction.OpenWiki)] [TestCase("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present).")] // note that there's 0 links here (they get removed if a channel is not found) - [TestCase("Join my multiplayer game osump://12346.", LinkAction.JoinMultiplayerMatch)] - [TestCase("Join my multiplayer gameosump://12346.", LinkAction.JoinMultiplayerMatch)] - [TestCase("Join my [multiplayer game](osump://12346).", LinkAction.JoinMultiplayerMatch)] + [TestCase("Join my multiplayer game osu://room/12346.", LinkAction.JoinRoom)] + [TestCase("Join my multiplayer gameosu://room/12346.", LinkAction.JoinRoom)] + [TestCase("Join my [multiplayer game](osu://room/12346).", LinkAction.JoinRoom)] + [TestCase("Join my multiplayer game http://dev.ppy.sh/multiplayer/rooms/12346", LinkAction.JoinRoom)] + [TestCase("Join my [multiplayer game](http://dev.ppy.sh/multiplayer/rooms/12346).", LinkAction.JoinRoom)] [TestCase($"Join my [#english]({OsuGameBase.OSU_PROTOCOL}chan/#english).", LinkAction.OpenChannel)] [TestCase($"Join my {OsuGameBase.OSU_PROTOCOL}chan/#english.", LinkAction.OpenChannel)] [TestCase($"Join my{OsuGameBase.OSU_PROTOCOL}chan/#english.", LinkAction.OpenChannel)] diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index ab9ee1d8cc..d0fc66252e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -215,6 +215,32 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestChannelCloseViaMiddleClick() + { + var testPMChannel = new Channel(testUser); + + AddStep("Show overlay", () => chatOverlay.Show()); + joinTestChannel(0); + joinChannel(testPMChannel); + AddStep("Select PM channel", () => clickDrawable(getChannelListItem(testPMChannel))); + AddStep("Middle click", () => + { + var item = getChannelListItem(testPMChannel); + InputManager.MoveMouseTo(item); + InputManager.Click(MouseButton.Middle); + }); + AddAssert("PM channel closed", () => !channelManager.JoinedChannels.Contains(testPMChannel)); + AddStep("Select normal channel", () => clickDrawable(getChannelListItem(testChannel1))); + AddStep("Click close button", () => + { + var item = getChannelListItem(testChannel1); + InputManager.MoveMouseTo(item); + InputManager.Click(MouseButton.Middle); + }); + AddAssert("Normal channel closed", () => !channelManager.JoinedChannels.Contains(testChannel1)); + } + [Test] public void TestChannelCloseButton() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs index b696c5d8ca..a1d0d40811 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs @@ -65,35 +65,38 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestBasicDisplay() { - AddStep("Begin watching user presence", () => metadataClient.BeginWatchingUserPresence()); + IDisposable token = null!; + + AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == 2); AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); - AddStep("User began playing", () => spectatorClient.SendStartPlay(streamingUser.Id, 0)); + AddStep("User began playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.True); - AddStep("User finished playing", () => spectatorClient.SendEndPlay(streamingUser.Id)); + AddStep("User finished playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); AddUntilStep("Panel no longer present", () => !currentlyOnline.ChildrenOfType().Any()); - AddStep("End watching user presence", () => metadataClient.EndWatchingUserPresence()); + AddStep("End watching user presence", () => token.Dispose()); } [Test] public void TestUserWasPlayingBeforeWatchingUserPresence() { - AddStep("User began playing", () => spectatorClient.SendStartPlay(streamingUser.Id, 0)); - AddStep("Begin watching user presence", () => metadataClient.BeginWatchingUserPresence()); - AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); - AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == 2); + IDisposable token = null!; + + AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); + AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); + AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.True); - AddStep("User finished playing", () => spectatorClient.SendEndPlay(streamingUser.Id)); + AddStep("User finished playing", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() })); AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType().First().Enabled.Value, () => Is.False); AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); - AddStep("End watching user presence", () => metadataClient.EndWatchingUserPresence()); + AddStep("End watching user presence", () => token.Dispose()); } internal partial class TestUserLookupCache : UserLookupCache diff --git a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs index fb54e936bc..1c946cfef9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Online { @@ -30,7 +31,7 @@ namespace osu.Game.Tests.Visual.Online if (supportLevel > 3) supportLevel = 0; - ((DummyAPIAccess)API).Friends.Add(new APIRelation + ((DummyAPIAccess)API).LocalUserState.Friends.Add(new APIRelation { TargetID = 2, RelationType = RelationType.Friend, @@ -40,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"peppy", Id = 2, Colour = "99EB47", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, IsSupporter = supportLevel > 0, SupportLevel = supportLevel } diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 7925b252b6..b9c1478fed 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -5,46 +5,228 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; using osu.Game.Overlays; using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.Metadata; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { public partial class TestSceneFriendDisplay : OsuTestScene { - protected override bool UseOnlineAPI => true; - [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); - private FriendDisplay display; + private TestMetadataClient metadataClient; [SetUp] public void Setup() => Schedule(() => { - Child = new BasicScrollContainer + Child = new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - Child = display = new FriendDisplay() + CachedDependencies = + [ + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], + Children = new Drawable[] + { + metadataClient, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FriendDisplay() + } + } }; }); [Test] - public void TestOffline() + public void TestAddAndRemoveFriends() { - AddStep("Populate with offline test users", () => display.Users = getUsers()); + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("remove one friend", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.RemoveAt(0); + }); + + waitForLoad(); + assertVisiblePanelCount(2); + + AddStep("add one friend", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.AddRange(getUsers().Take(1).Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); } [Test] - public void TestOnline() + public void TestChangeDisplayStyle() { - // No need to do anything, fetch is performed automatically. + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("set list style", () => this.ChildrenOfType().Single().DisplayStyle.Value = OverlayPanelDisplayStyle.List); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("set brick style", () => this.ChildrenOfType().Single().DisplayStyle.Value = OverlayPanelDisplayStyle.Brick); + + waitForLoad(); + assertVisiblePanelCount(3); + } + + [Test] + public void TestOnlinePresence() + { + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + waitForLoad(); + assertVisiblePanelCount(3); + + AddStep("change to online stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.Online); + assertVisiblePanelCount(0); + + AddStep("bring a friend online", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + metadataClient.FriendPresenceUpdated(api.LocalUserState.Friends[0].TargetID, new UserPresence { Status = UserStatus.Online }); + }); + + assertVisiblePanelCount(1); + + AddStep("change to offline stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.Offline); + assertVisiblePanelCount(2); + + AddStep("bring a friend online", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + metadataClient.FriendPresenceUpdated(api.LocalUserState.Friends[1].TargetID, new UserPresence { Status = UserStatus.Online }); + }); + + assertVisiblePanelCount(1); + + AddStep("change to online stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.Online); + assertVisiblePanelCount(2); + + AddStep("take friend offline", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + metadataClient.FriendPresenceUpdated(api.LocalUserState.Friends[1].TargetID, null); + }); + assertVisiblePanelCount(1); + + AddStep("change to all stream", () => this.ChildrenOfType().Single().Current.Value = OnlineStatus.All); + assertVisiblePanelCount(3); + } + + [Test] + public void TestLoadFriendsBeforeDisplay() + { + AddStep("set friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.AddRange(getUsers().Select(u => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = u.OnlineID, + TargetUser = u + })); + }); + + AddStep("load new display", () => + { + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], + Children = new Drawable[] + { + metadataClient, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FriendDisplay() + } + } + }; + }); + + waitForLoad(); + assertVisiblePanelCount(3); + } + + private void waitForLoad() + => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().First().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + private void assertVisiblePanelCount(int expectedVisible) + where T : UserPanel + { + AddAssert($"{typeof(T).ReadableName()}s in list", () => this.ChildrenOfType().Last().ChildrenOfType().All(p => p is T)); + AddAssert($"{expectedVisible} panels visible", () => this.ChildrenOfType().Last().ChildrenOfType().Count(p => p.IsPresent), + () => Is.EqualTo(expectedVisible)); } private List getUsers() => new List @@ -53,19 +235,19 @@ namespace osu.Game.Tests.Visual.Online { Username = "flyte", Id = 3103765, - IsOnline = true, + WasRecentlyOnline = true, Statistics = new UserStatistics { GlobalRank = 1111 }, CountryCode = CountryCode.JP, - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" + CoverUrl = TestResources.COVER_IMAGE_4 }, new APIUser { Username = "peppy", Id = 2, - IsOnline = false, + WasRecentlyOnline = false, Statistics = new UserStatistics { GlobalRank = 2222 }, CountryCode = CountryCode.AU, - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, IsSupporter = true, SupportLevel = 3, }, @@ -75,7 +257,7 @@ namespace osu.Game.Tests.Visual.Online Id = 8195163, CountryCode = CountryCode.BY, CoverUrl = "https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - IsOnline = false, + WasRecentlyOnline = false, LastVisit = DateTimeOffset.Now } }; diff --git a/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs new file mode 100644 index 0000000000..beabf6711c --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.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.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Tests.Visual.UserInterface; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneGlobalRankDisplay : ThemeComparisonTestScene + { + public TestSceneGlobalRankDisplay() + : base(false) + { + } + + protected override Drawable CreateContent() => new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Full, + Padding = new MarginPadding(20), + Spacing = new Vector2(40), + ChildrenEnumerable = new int?[] { 64, 423, 1_453, 3_468, 8_367, 48_342, 78_432, 375_231, 897_783, null }.Select(createDisplay) + }; + + private GlobalRankDisplay createDisplay(int? rank) => new GlobalRankDisplay + { + UserStatistics = + { + Value = new UserStatistics + { + GlobalRank = rank, + GlobalRankPercent = rank / 1_000_000f, + Variants = + [ + new UserStatistics.Variant + { + VariantType = UserStatistics.RulesetVariant.FourKey, + GlobalRank = rank / 3, + }, + new UserStatistics.Variant + { + VariantType = UserStatistics.RulesetVariant.SevenKey, + GlobalRank = 2 * rank / 3, + } + ] + }, + }, + HighestRank = + { + Value = rank == null + ? null + : new APIUser.UserRankHighest + { + Rank = rank.Value / 2, + UpdatedAt = DateTimeOffset.Now.AddMonths(-3), + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs b/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs new file mode 100644 index 0000000000..60b10b9899 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneImageProxying.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.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Tests.Visual.Online +{ + [Ignore("This test hits online resources (and online retrieval can fail at any time), and also performs network calls to the production instance of the website. Un-ignore this test when it's actually actively needed.")] + public partial class TestSceneImageProxying : OsuTestScene + { + [Test] + public void TestExternalImageLink() + { + MarkdownContainer markdown = null!; + + // use base MarkdownContainer as a method of directly attempting to load an image without proxying logic. + AddStep("load external without proxying", () => Child = markdown = new MarkdownContainer + { + RelativeSizeAxes = Axes.Both, + Text = "![](https://github.com/ppy/osu-wiki/blob/master/wiki/Announcement_messages/img/notification.png?raw=true)", + }); + AddWaitStep("wait", 5); + AddAssert("image not loaded", () => markdown.ChildrenOfType().SingleOrDefault()?.Texture == null); + + AddStep("load external with proxying", () => Child = markdown = new OsuMarkdownContainer + { + RelativeSizeAxes = Axes.Both, + Text = "![](https://github.com/ppy/osu-wiki/blob/master/wiki/Announcement_messages/img/notification.png?raw=true)", + }); + AddUntilStep("image loaded", () => markdown.ChildrenOfType().SingleOrDefault()?.Texture != null); + } + + [Test] + public void TestExternalImageLinkInComments() + { + MarkdownContainer markdown = null!; + + AddStep("load external with proxying", () => Child = markdown = new CommentMarkdownContainer + { + RelativeSizeAxes = Axes.Both, + Text = "![](https://github.com/ppy/osu-wiki/blob/master/wiki/Announcement_messages/img/notification.png?raw=true)", + }); + AddUntilStep("image loaded", () => markdown.ChildrenOfType().SingleOrDefault()?.Texture != null); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs index ba2b160fd1..274d7f0c51 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs @@ -40,8 +40,8 @@ namespace osu.Game.Tests.Visual.Online daa.HandleRequest = dummyAPIHandleRequest; } - friend = new APIUser { Id = 0, Username = "Friend" }; - publicChannel = new Channel { Id = 1, Name = "osu" }; + friend = new APIUser { Id = 0, Username = "SomeFriend" }; + publicChannel = new Channel { Id = 1, Name = "#osu" }; privateMessageChannel = new Channel(friend) { Id = 2, Name = friend.Username, Type = ChannelType.PM }; Schedule(() => @@ -93,6 +93,17 @@ namespace osu.Game.Tests.Visual.Online } } + [Test] + public void TestLongMessages() + { + AddStep("close overlay", () => testContainer.ChatOverlay.Hide()); + + AddStep("long public", () => receiveMessage(friend, publicChannel, $"For some reason there were no tests testing very long messages, even though there should have been. Why {API.LocalUser.Value.Username} why?")); + + AddStep("long private", + () => receiveMessage(friend, privateMessageChannel, "For no good reason, we were not testing very long messages and how the notifications display when the message can't fit")); + } + [Test] public void TestPublicChannelMention() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs index 1e9b0317fb..56d03d4c7f 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs @@ -8,7 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Online.API; +using osu.Game.Configuration; using osu.Game.Online.Chat; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; @@ -23,17 +23,23 @@ namespace osu.Game.Tests.Visual.Online [Cached(typeof(IChannelPostTarget))] private PostTarget postTarget { get; set; } - private DummyAPIAccess api => (DummyAPIAccess)API; + private SessionStatics session = null!; public TestSceneNowPlayingCommand() { Add(postTarget = new PostTarget()); } + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(session = new SessionStatics()); + } + [Test] public void TestGenericActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InLobby(new Room())); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InLobby(new Room()))); AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); @@ -43,7 +49,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestEditActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo())); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.EditingBeatmap(new BeatmapInfo()))); AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); @@ -53,7 +59,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestPlayActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo)); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo))); AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); @@ -64,7 +70,7 @@ namespace osu.Game.Tests.Visual.Online [TestCase(false)] public void TestLinkPresence(bool hasOnlineId) { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InLobby(new Room())); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InLobby(new Room()))); AddStep("Set beatmap", () => Beatmap.Value = new DummyWorkingBeatmap(Audio, null) { @@ -82,7 +88,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestModPresence() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo)); + AddStep("Set activity", () => session.SetValue(Static.UserOnlineActivity, new UserActivity.InSoloGame(new BeatmapInfo(), new OsuRuleset().RulesetInfo))); AddStep("Add Hidden mod", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateMod() }); diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index d7f79d3e30..877dc7eaac 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -471,7 +471,7 @@ namespace osu.Game.Tests.Visual.Online public DrawableChannel DrawableChannel => InternalChildren.OfType().First(); - public ChannelScrollContainer ScrollContainer => (ChannelScrollContainer)((Container)DrawableChannel.Child).Child; + public ChannelScrollContainer ScrollContainer => DrawableChannel.ChildrenOfType().Single(); public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child; diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs b/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs index 4539eae25f..3333eae567 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Testing; using osu.Game.Graphics.Cursor; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Tests.Resources; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; @@ -30,9 +31,9 @@ namespace osu.Game.Tests.Visual.Online Spacing = new Vector2(10f), Children = new[] { - generateUser(@"peppy", 2, CountryCode.AU, @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", false, "99EB47"), - generateUser(@"flyte", 3103765, CountryCode.JP, @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg", true), - generateUser(@"joshika39", 17032217, CountryCode.RS, @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", false), + generateUser(@"peppy", 2, CountryCode.AU, TestResources.COVER_IMAGE_3, false, "99EB47"), + generateUser(@"flyte", 3103765, CountryCode.JP, TestResources.COVER_IMAGE_4, true), + generateUser(@"joshika39", 17032217, CountryCode.RS, TestResources.COVER_IMAGE_3, false), new UpdateableAvatar(), new UpdateableAvatar() }, @@ -62,10 +63,7 @@ namespace osu.Game.Tests.Visual.Online CountryCode = countryCode, CoverUrl = cover, Colour = color ?? "000000", - Status = - { - Value = UserStatus.Online - }, + WasRecentlyOnline = true }; return new ClickableAvatar(user, showPanel) diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 3f1d961588..896bda364a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -4,17 +4,18 @@ using System; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.Metadata; using osu.Game.Users; using osuTK; @@ -23,144 +24,142 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public partial class TestSceneUserPanel : OsuTestScene { - private readonly Bindable activity = new Bindable(); - private readonly Bindable status = new Bindable(); - - private UserGridPanel boundPanel1 = null!; - private TestUserListPanel boundPanel2 = null!; - [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); - [Cached(typeof(LocalUserStatisticsProvider))] - private readonly TestUserStatisticsProvider statisticsProvider = new TestUserStatisticsProvider(); - [Resolved] private IRulesetStore rulesetStore { get; set; } = null!; + private TestUserStatisticsProvider statisticsProvider = null!; + private TestMetadataClient metadataClient = null!; + private TestUserListPanel panel = null!; + [SetUp] public void SetUp() => Schedule(() => { - activity.Value = null; - status.Value = null; - - Remove(statisticsProvider, false); - Clear(); - Add(statisticsProvider); - - Add(new FillFlowContainer + Child = new DependencyProvidingContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Spacing = new Vector2(10f), + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LocalUserStatisticsProvider), statisticsProvider = new TestUserStatisticsProvider()), + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], Children = new Drawable[] { - new UserBrickPanel(new APIUser + statisticsProvider, + metadataClient, + new FillFlowContainer { - Username = @"flyte", - Id = 3103765, - CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", - }), - new UserBrickPanel(new APIUser - { - Username = @"peppy", - Id = 2, - Colour = "99EB47", - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - }), - new UserGridPanel(new APIUser - { - Username = @"flyte", - Id = 3103765, - CountryCode = CountryCode.JP, - CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", - Status = { Value = UserStatus.Online } - }) { Width = 300 }, - boundPanel1 = new UserGridPanel(new APIUser - { - Username = @"peppy", - Id = 2, - CountryCode = CountryCode.AU, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - IsSupporter = true, - SupportLevel = 3, - }) { Width = 300 }, - boundPanel2 = new TestUserListPanel(new APIUser - { - Username = @"Evast", - Id = 8195163, - CountryCode = CountryCode.BY, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - IsOnline = false, - LastVisit = DateTimeOffset.Now - }), - new UserRankPanel(new APIUser - { - Username = @"flyte", - Id = 3103765, - CountryCode = CountryCode.JP, - CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", - Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 } - }) { Width = 300 }, - new UserRankPanel(new APIUser - { - Username = @"peppy", - Id = 2, - Colour = "99EB47", - CountryCode = CountryCode.AU, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", - Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } - }) { Width = 300 } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(10f), + Children = new Drawable[] + { + new UserBrickPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + }), + new UserBrickPanel(new APIUser + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + }), + new UserGridPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CountryCode = CountryCode.JP, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + WasRecentlyOnline = true + }) { Width = 300 }, + new UserGridPanel(new APIUser + { + Username = @"peppy", + Id = 2, + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + IsSupporter = true, + SupportLevel = 3, + }) { Width = 300 }, + panel = new TestUserListPanel(new APIUser + { + Username = @"peppy", + Id = 2, + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + LastVisit = DateTimeOffset.Now + }), + new UserRankPanel(new APIUser + { + Username = @"flyte", + Id = 3103765, + CountryCode = CountryCode.JP, + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", + Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 } + }) { Width = 300 }, + new UserRankPanel(new APIUser + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } + }) { Width = 300 }, + new UserGridPanel(API.LocalUser.Value) + { + Width = 300 + } + } + } } - }); + }; - boundPanel1.Status.BindTo(status); - boundPanel1.Activity.BindTo(activity); - - boundPanel2.Status.BindTo(status); - boundPanel2.Activity.BindTo(activity); + metadataClient.BeginWatchingUserPresence(); }); [Test] public void TestUserStatus() { - AddStep("online", () => status.Value = UserStatus.Online); - AddStep("do not disturb", () => status.Value = UserStatus.DoNotDisturb); - AddStep("offline", () => status.Value = UserStatus.Offline); - AddStep("null status", () => status.Value = null); + AddStep("online", () => setPresence(UserStatus.Online, null)); + AddStep("do not disturb", () => setPresence(UserStatus.DoNotDisturb, null)); + AddStep("offline", () => setPresence(UserStatus.Offline, null)); } [Test] public void TestUserActivity() { - AddStep("set online status", () => status.Value = UserStatus.Online); - - AddStep("idle", () => activity.Value = null); - AddStep("watching replay", () => activity.Value = new UserActivity.WatchingReplay(createScore(@"nats"))); - AddStep("spectating user", () => activity.Value = new UserActivity.SpectatingUser(createScore(@"mrekk"))); - AddStep("solo (osu!)", () => activity.Value = soloGameStatusForRuleset(0)); - AddStep("solo (osu!taiko)", () => activity.Value = soloGameStatusForRuleset(1)); - AddStep("solo (osu!catch)", () => activity.Value = soloGameStatusForRuleset(2)); - AddStep("solo (osu!mania)", () => activity.Value = soloGameStatusForRuleset(3)); - AddStep("choosing", () => activity.Value = new UserActivity.ChoosingBeatmap()); - AddStep("editing beatmap", () => activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo())); - AddStep("modding beatmap", () => activity.Value = new UserActivity.ModdingBeatmap(new BeatmapInfo())); - AddStep("testing beatmap", () => activity.Value = new UserActivity.TestingBeatmap(new BeatmapInfo())); + AddStep("idle", () => setPresence(UserStatus.Online, null)); + AddStep("watching replay", () => setPresence(UserStatus.Online, new UserActivity.WatchingReplay(createScore(@"nats")))); + AddStep("spectating user", () => setPresence(UserStatus.Online, new UserActivity.SpectatingUser(createScore(@"mrekk")))); + AddStep("solo (osu!)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(0))); + AddStep("solo (osu!taiko)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(1))); + AddStep("solo (osu!catch)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(2))); + AddStep("solo (osu!mania)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(3))); + AddStep("choosing", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap())); + AddStep("editing beatmap", () => setPresence(UserStatus.Online, new UserActivity.EditingBeatmap(new BeatmapInfo()))); + AddStep("modding beatmap", () => setPresence(UserStatus.Online, new UserActivity.ModdingBeatmap(new BeatmapInfo()))); + AddStep("testing beatmap", () => setPresence(UserStatus.Online, new UserActivity.TestingBeatmap(new BeatmapInfo()))); } [Test] public void TestUserActivityChange() { - AddAssert("visit message is visible", () => boundPanel2.LastVisitMessage.IsPresent); - AddStep("set online status", () => status.Value = UserStatus.Online); - AddAssert("visit message is not visible", () => !boundPanel2.LastVisitMessage.IsPresent); - AddStep("set choosing activity", () => activity.Value = new UserActivity.ChoosingBeatmap()); - AddStep("set offline status", () => status.Value = UserStatus.Offline); - AddAssert("visit message is visible", () => boundPanel2.LastVisitMessage.IsPresent); - AddStep("set online status", () => status.Value = UserStatus.Online); - AddAssert("visit message is not visible", () => !boundPanel2.LastVisitMessage.IsPresent); + AddAssert("visit message is visible", () => panel.LastVisitMessage.IsPresent); + AddStep("set online status", () => setPresence(UserStatus.Online, null)); + AddAssert("visit message is not visible", () => !panel.LastVisitMessage.IsPresent); + AddStep("set choosing activity", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap())); + AddStep("set offline status", () => setPresence(UserStatus.Offline, null)); + AddAssert("visit message is visible", () => panel.LastVisitMessage.IsPresent); + AddStep("set online status", () => setPresence(UserStatus.Online, null)); + AddAssert("visit message is not visible", () => !panel.LastVisitMessage.IsPresent); } [Test] @@ -185,6 +184,31 @@ namespace osu.Game.Tests.Visual.Online AddStep("set statistics to empty", () => statisticsProvider.UpdateStatistics(new UserStatistics(), Ruleset.Value)); } + [Test] + public void TestLocalUserActivity() + { + AddStep("idle", () => setPresence(UserStatus.Online, null, API.LocalUser.Value.OnlineID)); + AddStep("watching replay", () => setPresence(UserStatus.Online, new UserActivity.WatchingReplay(createScore(@"nats")), API.LocalUser.Value.OnlineID)); + AddStep("spectating user", () => setPresence(UserStatus.Online, new UserActivity.SpectatingUser(createScore(@"mrekk")), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(0), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!taiko)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(1), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!catch)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(2), API.LocalUser.Value.OnlineID)); + AddStep("solo (osu!mania)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(3), API.LocalUser.Value.OnlineID)); + AddStep("choosing", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap(), API.LocalUser.Value.OnlineID)); + AddStep("editing beatmap", () => setPresence(UserStatus.Online, new UserActivity.EditingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID)); + AddStep("modding beatmap", () => setPresence(UserStatus.Online, new UserActivity.ModdingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID)); + AddStep("testing beatmap", () => setPresence(UserStatus.Online, new UserActivity.TestingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID)); + AddStep("set offline status", () => setPresence(UserStatus.Offline, null, API.LocalUser.Value.OnlineID)); + } + + private void setPresence(UserStatus status, UserActivity? activity, int? userId = null) + { + if (status == UserStatus.Offline) + metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, null); + else + metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, new UserPresence { Status = status, Activity = activity }); + } + private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.InSoloGame(new BeatmapInfo(), rulesetStore.GetRuleset(rulesetId)!); private ScoreInfo createScore(string name) => new ScoreInfo(new TestBeatmap(Ruleset.Value).BeatmapInfo) diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs index 0477d39193..2be9c1ab14 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Profile; @@ -20,24 +21,16 @@ namespace osu.Game.Tests.Visual.Online public partial class TestSceneUserProfileDailyChallenge : OsuManualInputManagerTestScene { [Cached] - public readonly Bindable User = new Bindable(new UserProfileData(new APIUser(), new OsuRuleset().RulesetInfo)); + private readonly Bindable userProfileData = new Bindable(new UserProfileData(new APIUser(), new OsuRuleset().RulesetInfo)); [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); - protected override void LoadComplete() + private DailyChallengeStatsDisplay display = null!; + + [SetUpSteps] + public void SetUpSteps() { - base.LoadComplete(); - - DailyChallengeStatsDisplay display = null!; - - AddSliderStep("daily", 0, 999, 2, v => update(s => s.DailyStreakCurrent = v)); - AddSliderStep("daily best", 0, 999, 2, v => update(s => s.DailyStreakBest = v)); - AddSliderStep("weekly", 0, 250, 1, v => update(s => s.WeeklyStreakCurrent = v)); - AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v)); - AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v)); - AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v)); - AddSliderStep("playcount", 0, 1500, 1, v => update(s => s.PlayCount = v)); AddStep("create", () => { Clear(); @@ -51,16 +44,40 @@ namespace osu.Game.Tests.Visual.Online Anchor = Anchor.Centre, Origin = Anchor.Centre, Scale = new Vector2(1f), - User = { BindTarget = User }, + User = { BindTarget = userProfileData }, }); }); + + AddStep("set local user", () => update(s => s.UserID = API.LocalUser.Value.Id)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("daily", 0, 999, 2, v => update(s => s.DailyStreakCurrent = v)); + AddSliderStep("daily best", 0, 999, 2, v => update(s => s.DailyStreakBest = v)); + AddSliderStep("weekly", 0, 250, 1, v => update(s => s.WeeklyStreakCurrent = v)); + AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v)); + AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v)); + AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v)); + AddSliderStep("playcount", 0, 1500, 1, v => update(s => s.PlayCount = v)); + } + + [Test] + public void TestStates() + { + AddStep("played today", () => update(s => s.LastUpdate = DateTimeOffset.UtcNow.Date)); + AddStep("played yesterday", () => update(s => s.LastUpdate = DateTimeOffset.UtcNow.Date.AddDays(-1))); + AddStep("change to non-local user", () => update(s => s.UserID = API.LocalUser.Value.Id + 1000)); + AddStep("hover", () => InputManager.MoveMouseTo(display)); } private void update(Action change) { - change.Invoke(User.Value!.User.DailyChallengeStatistics); - User.Value = new UserProfileData(User.Value.User, User.Value.Ruleset); + change.Invoke(userProfileData.Value!.User.DailyChallengeStatistics); + userProfileData.Value = new UserProfileData(userProfileData.Value.User, userProfileData.Value.Ruleset); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index 6167d1f760..adfe95a41c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -18,6 +18,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Rulesets.Osu; +using osu.Game.Tests.Resources; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -79,7 +80,7 @@ namespace osu.Game.Tests.Visual.Online Id = 1001, Username = "IAmOnline", LastVisit = DateTimeOffset.Now, - IsOnline = true, + WasRecentlyOnline = true, }, new OsuRuleset().RulesetInfo)); AddStep("Show offline user", () => header.User.Value = new UserProfileData(new APIUser @@ -87,7 +88,7 @@ namespace osu.Game.Tests.Visual.Online Id = 1002, Username = "IAmOffline", LastVisit = DateTimeOffset.Now.AddDays(-10), - IsOnline = false, + WasRecentlyOnline = false, }, new OsuRuleset().RulesetInfo)); } @@ -136,7 +137,7 @@ namespace osu.Game.Tests.Visual.Online { Id = 727, Username = "SomeoneIndecisive", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + CoverUrl = TestResources.COVER_IMAGE_1, Groups = new[] { new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, @@ -162,7 +163,7 @@ namespace osu.Game.Tests.Visual.Online { Id = 728, Username = "Certain Guy", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, Statistics = new UserStatistics { IsRanked = false, @@ -442,7 +443,7 @@ namespace osu.Game.Tests.Visual.Online Task.Run(() => { requestLock.Wait(3000); - dummyAPI.Friends.Add(apiRelation); + dummyAPI.LocalUserState.Friends.Add(apiRelation); req.TriggerSuccess(new AddFriendResponse { UserRelation = apiRelation @@ -452,11 +453,11 @@ namespace osu.Game.Tests.Visual.Online return true; }; }); - AddStep("clear friend list", () => dummyAPI.Friends.Clear()); + AddStep("clear friend list", () => dummyAPI.LocalUserState.Friends.Clear()); AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo)); AddStep("Click followers button", () => this.ChildrenOfType().First().TriggerClick()); AddStep("Complete request", () => requestLock.Set()); - AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID)); + AddUntilStep("Friend added", () => API.LocalUserState.Friends.Any(f => f.TargetID == nonFriend.OnlineID)); } [Test] @@ -485,7 +486,7 @@ namespace osu.Game.Tests.Visual.Online Task.Run(() => { requestLock.Wait(3000); - dummyAPI.Friends.Add(apiRelation); + dummyAPI.LocalUserState.Friends.Add(apiRelation); req.TriggerSuccess(new AddFriendResponse { UserRelation = apiRelation @@ -495,11 +496,11 @@ namespace osu.Game.Tests.Visual.Online return true; }; }); - AddStep("clear friend list", () => dummyAPI.Friends.Clear()); + AddStep("clear friend list", () => dummyAPI.LocalUserState.Friends.Clear()); AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo)); AddStep("Click followers button", () => this.ChildrenOfType().First().TriggerClick()); AddStep("Complete request", () => requestLock.Set()); - AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID)); + AddUntilStep("Friend added", () => API.LocalUserState.Friends.Any(f => f.TargetID == nonFriend.OnlineID)); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index d16ed46bd2..1c2fdc7860 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -13,6 +13,7 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets.Taiko; +using osu.Game.Tests.Resources; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -152,7 +153,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue}", Id = 1, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue, PlayMode = "osu", }); @@ -196,7 +197,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue}", Id = 1, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue, PlayMode = "osu", })); @@ -212,7 +213,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue2}", Id = 2, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue2, PlayMode = "osu", })); @@ -225,7 +226,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue2}", Id = 2, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue2, PlayMode = "osu", })); @@ -236,7 +237,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"Somebody", Id = 1, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + CoverUrl = TestResources.COVER_IMAGE_1, JoinDate = DateTimeOffset.Now.AddDays(-1), LastVisit = DateTimeOffset.Now, Groups = new[] @@ -346,6 +347,13 @@ namespace osu.Game.Tests.Visual.Online Twitter = "test_user", Discord = "test_user", Website = "https://google.com", + Team = new APITeam + { + Id = 1, + Name = "Collective Wangs", + ShortName = "WANG", + FlagUrl = "https://assets.ppy.sh/teams/flag/1/wanglogo.jpg", + } }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs index 56e4348b65..7cb5b4fbf5 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs @@ -31,7 +31,8 @@ namespace osu.Game.Tests.Visual.Online Title = "JUSTadICE (TV Size)", Artist = "Oomori Seiko", }, - DifficultyName = "Extreme" + DifficultyName = "Extreme", + Status = BeatmapOnlineStatus.Ranked, }, EndedAt = DateTimeOffset.Now, Mods = new[] @@ -42,6 +43,8 @@ namespace osu.Game.Tests.Visual.Online }, Accuracy = 0.9813, Ranked = true, + Preserve = true, + Processed = true, }; var secondScore = new SoloScoreInfo @@ -55,7 +58,8 @@ namespace osu.Game.Tests.Visual.Online Title = "Triumph & Regret", Artist = "typeMARS", }, - DifficultyName = "[4K] Regret" + DifficultyName = "[4K] Regret", + Status = BeatmapOnlineStatus.Ranked, }, EndedAt = DateTimeOffset.Now, Mods = new[] @@ -65,6 +69,8 @@ namespace osu.Game.Tests.Visual.Online }, Accuracy = 0.998546, Ranked = true, + Preserve = true, + Processed = true, }; var thirdScore = new SoloScoreInfo @@ -78,11 +84,14 @@ namespace osu.Game.Tests.Visual.Online Title = "Idolize", Artist = "Creo", }, - DifficultyName = "Insane" + DifficultyName = "Insane", + Status = BeatmapOnlineStatus.Ranked, }, EndedAt = DateTimeOffset.Now, Accuracy = 0.9726, Ranked = true, + Preserve = true, + Processed = true, }; var noPPScore = new SoloScoreInfo @@ -95,11 +104,14 @@ namespace osu.Game.Tests.Visual.Online Title = "C18H27NO3(extend)", Artist = "Team Grimoire", }, - DifficultyName = "[4K] Cataclysmic Hypernova" + DifficultyName = "[4K] Cataclysmic Hypernova", + Status = BeatmapOnlineStatus.Ranked, }, EndedAt = DateTimeOffset.Now, Accuracy = 0.55879, Ranked = true, + Preserve = true, + Processed = true, }; var lovedScore = new SoloScoreInfo @@ -118,6 +130,8 @@ namespace osu.Game.Tests.Visual.Online EndedAt = DateTimeOffset.Now, Accuracy = 0.55879, Ranked = true, + Preserve = true, + Processed = true, }; var unprocessedPPScore = new SoloScoreInfo @@ -136,6 +150,8 @@ namespace osu.Game.Tests.Visual.Online EndedAt = DateTimeOffset.Now, Accuracy = 0.55879, Ranked = true, + Preserve = true, + Processed = false, }; var unrankedPPScore = new SoloScoreInfo @@ -153,7 +169,31 @@ namespace osu.Game.Tests.Visual.Online }, EndedAt = DateTimeOffset.Now, Accuracy = 0.55879, + PP = 96.83, Ranked = false, + Preserve = true, + Processed = true, + }; + + var notPreservedPPScore = new SoloScoreInfo + { + Rank = ScoreRank.B, + Beatmap = new APIBeatmap + { + BeatmapSet = new APIBeatmapSet + { + Title = "C18H27NO3(extend)", + Artist = "Team Grimoire", + }, + DifficultyName = "[4K] Cataclysmic Hypernova", + Status = BeatmapOnlineStatus.Ranked, + }, + EndedAt = DateTimeOffset.Now, + Accuracy = 0.55879, + PP = 96.83, + Ranked = true, + Preserve = false, + Processed = true, }; Add(new FillFlowContainer @@ -172,6 +212,7 @@ namespace osu.Game.Tests.Visual.Online new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(lovedScore)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unprocessedPPScore)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unrankedPPScore)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(notPreservedPPScore)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(firstScore, 0.97)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(secondScore, 0.85)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(thirdScore, 0.66)), diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index 8909305602..e453a32652 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -67,19 +67,19 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestLink() { - AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/"); + AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/"); AddStep("set '/wiki/Main_page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_page)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_page"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Main_page"); AddStep("set '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/FAQ"); AddStep("set './Writing''", () => markdownContainer.Text = "[wiki writing guidline](./Writing)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Writing"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/Writing"); AddStep("set 'Formatting''", () => markdownContainer.Text = "[wiki formatting guidline](Formatting)"); - AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Formatting"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/Formatting"); } [Test] diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs b/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs new file mode 100644 index 0000000000..7a11581d27 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneAddPlaylistToCollectionButton.cs @@ -0,0 +1,126 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Playlists; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneAddPlaylistToCollectionButton : OsuManualInputManagerTestScene + { + private RulesetStore rulesets = null!; + private BeatmapManager manager = null!; + private BeatmapSetInfo importedBeatmap = null!; + private Room room = null!; + private AddPlaylistToCollectionButton button = null!; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Realm); + + Add(notificationOverlay); + } + + [Cached(typeof(INotificationOverlay))] + private NotificationOverlay notificationOverlay = new NotificationOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear realm", () => Realm.Realm.Write(() => Realm.Realm.RemoveAll())); + + AddStep("clear notifications", () => + { + foreach (var notification in notificationOverlay.AllNotifications) + notification.Close(runFlingAnimation: false); + }); + + importBeatmap(); + + setupRoom(); + + AddStep("create button", () => + { + Add(button = new AddPlaylistToCollectionButton(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 40), + }); + }); + } + + [Test] + public void TestButtonFlow() + { + AddStep("move mouse to button", () => InputManager.MoveMouseTo(button)); + + AddStep("click button", () => InputManager.Click(MouseButton.Left)); + + AddUntilStep("notification shown", () => notificationOverlay.AllNotifications.Any(n => n.Text.ToString().StartsWith("Created new collection", StringComparison.Ordinal))); + + AddUntilStep("realm is updated", () => Realm.Realm.All().FirstOrDefault(c => c.Name == room.Name) != null); + } + + private void importBeatmap() => AddStep("import beatmap", () => + { + var beatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); + + Debug.Assert(beatmap.BeatmapInfo.BeatmapSet != null); + + importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)!.Value.Detach(); + }); + + private void setupRoom() => AddStep("setup room", () => + { + room = new Room + { + Name = "my awesome room", + MaxAttempts = 5, + Host = API.LocalUser.Value + }; + room.RecentParticipants = [room.Host]; + room.EndDate = DateTimeOffset.Now.AddMinutes(5); + room.Playlist = + [ + new PlaylistItem(importedBeatmap.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index 8c8dc8d69a..f2eeb5363a 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -3,7 +3,6 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Graphics.Containers; @@ -17,25 +16,22 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsLoungeSubScreen : OnlinePlayTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - - private TestLoungeSubScreen loungeScreen = null!; + private PlaylistsLoungeSubScreen loungeScreen = null!; public override void SetUpSteps() { base.SetUpSteps(); - AddStep("push screen", () => LoadScreen(loungeScreen = new TestLoungeSubScreen())); - + AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen())); AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); } - private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType().First(); + private RoomListing roomListing => loungeScreen.ChildrenOfType().First(); [Test] public void TestManyRooms() { - AddStep("add rooms", () => RoomManager.AddRooms(500)); + createRooms(GenerateRooms(500)); } [Test] @@ -43,59 +39,41 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("reset mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddStep("add rooms", () => RoomManager.AddRooms(30)); - AddUntilStep("wait for rooms", () => roomsContainer.Rooms.Count == 30); + createRooms(GenerateRooms(30)); - AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); - - AddStep("move mouse to third room", () => InputManager.MoveMouseTo(roomsContainer.Rooms[2])); + AddStep("move mouse to third room", () => InputManager.MoveMouseTo(roomListing.DrawableRooms[2])); AddStep("hold down", () => InputManager.PressButton(MouseButton.Left)); - AddStep("drag to top", () => InputManager.MoveMouseTo(roomsContainer.Rooms[0])); + AddStep("drag to top", () => InputManager.MoveMouseTo(roomListing.DrawableRooms[0])); AddAssert("first and second room masked", () - => !checkRoomVisible(roomsContainer.Rooms[0]) && - !checkRoomVisible(roomsContainer.Rooms[1])); + => !checkRoomVisible(roomListing.DrawableRooms[0]) && + !checkRoomVisible(roomListing.DrawableRooms[1])); } [Test] public void TestScrollSelectedIntoView() { - AddStep("add rooms", () => RoomManager.AddRooms(30)); - AddUntilStep("wait for rooms", () => roomsContainer.Rooms.Count == 30); + createRooms(GenerateRooms(30)); - AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); + AddStep("select last room", () => roomListing.DrawableRooms[^1].TriggerClick()); - AddStep("select last room", () => roomsContainer.Rooms[^1].TriggerClick()); - - AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms[0])); - AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms[^1])); + AddUntilStep("first room is masked", () => !checkRoomVisible(roomListing.DrawableRooms[0])); + AddUntilStep("last room is not masked", () => checkRoomVisible(roomListing.DrawableRooms[^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) => + private bool checkRoomVisible(RoomPanel panel) => loungeScreen.ChildrenOfType().First().ScreenSpaceDrawQuad - .Contains(room.ScreenSpaceDrawQuad.Centre); + .Contains(panel.ScreenSpaceDrawQuad.Centre); - private partial class TestLoungeSubScreen : PlaylistsLoungeSubScreen + private void createRooms(params Room[] rooms) { - public new Bindable SelectedRoom => base.SelectedRoom; + AddStep("create rooms", () => + { + foreach (var room in rooms) + API.Queue(new CreateRoomRequest(room)); + }); + + AddStep("refresh lounge", () => loungeScreen.RefreshRooms()); } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index 5868331451..c714c39e22 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs @@ -3,14 +3,13 @@ using System; using NUnit.Framework; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Visual.OnlinePlay; @@ -18,21 +17,38 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsMatchSettingsOverlay : OnlinePlayTestScene { - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - private TestRoomSettings settings = null!; - - protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); + private Room room = null!; + private Func? handleRequest; public override void SetUpSteps() { base.SetUpSteps(); + AddStep("setup api", () => + { + handleRequest = null; + ((DummyAPIAccess)API).HandleRequest = req => + { + if (req is not CreateRoomRequest createReq || handleRequest == null) + return false; + + if (handleRequest(createReq.Room) is string errorText) + createReq.TriggerFailure(new APIException(errorText, null)); + else + { + var createdRoom = new APICreatedRoom(); + createdRoom.CopyFrom(createReq.Room); + createReq.TriggerSuccess(createdRoom); + } + + return true; + }; + }); + AddStep("create overlay", () => { - SelectedRoom.Value = new Room(); - - Child = settings = new TestRoomSettings(SelectedRoom.Value!) + Child = settings = new TestRoomSettings(room = new Room()) { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible } @@ -45,19 +61,19 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("clear name and beatmap", () => { - SelectedRoom.Value!.Name = ""; - SelectedRoom.Value!.Playlist = []; + room.Name = ""; + room.Playlist = []; }); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set name", () => SelectedRoom.Value!.Name = "Room name"); + AddStep("set name", () => room.Name = "Room name"); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set beatmap", () => SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]); + AddStep("set beatmap", () => room.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]); AddAssert("button enabled", () => settings.ApplyButton.Enabled.Value); - AddStep("clear name", () => SelectedRoom.Value!.Name = ""); + AddStep("clear name", () => room.Name = ""); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); } @@ -73,12 +89,12 @@ namespace osu.Game.Tests.Visual.Playlists { settings.NameField.Current.Value = expected_name; settings.DurationField.Current.Value = expectedDuration; - SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; + room.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; - RoomManager.CreateRequested = r => + handleRequest = r => { createdRoom = r; - return string.Empty; + return null; }; }); @@ -98,22 +114,22 @@ namespace osu.Game.Tests.Visual.Playlists { var beatmap = CreateBeatmap(Ruleset.Value).BeatmapInfo; - SelectedRoom.Value!.Name = "Test Room"; - SelectedRoom.Value!.Playlist = [new PlaylistItem(beatmap)]; + room.Name = "Test Room"; + room.Playlist = [new PlaylistItem(beatmap)]; errorMessage = $"{not_found_prefix} {beatmap.OnlineID}"; - RoomManager.CreateRequested = _ => errorMessage; + handleRequest = _ => errorMessage; }); AddAssert("error not displayed", () => !settings.ErrorText.IsPresent); - AddAssert("playlist item valid", () => SelectedRoom.Value!.Playlist[0].Valid.Value); + AddAssert("playlist item valid", () => room.Playlist[0].Valid.Value); AddStep("create room", () => settings.ApplyButton.Action.Invoke()); AddAssert("error displayed", () => settings.ErrorText.IsPresent); AddAssert("error has custom text", () => settings.ErrorText.Text != errorMessage); - AddAssert("playlist item marked invalid", () => !SelectedRoom.Value!.Playlist[0].Valid.Value); + AddAssert("playlist item marked invalid", () => !room.Playlist[0].Valid.Value); } [Test] @@ -125,10 +141,10 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("setup", () => { - SelectedRoom.Value!.Name = "Test Room"; - SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; + room.Name = "Test Room"; + room.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; - RoomManager.CreateRequested = _ => failText; + handleRequest = _ => failText; }); AddAssert("error not displayed", () => !settings.ErrorText.IsPresent); @@ -159,48 +175,5 @@ namespace osu.Game.Tests.Visual.Playlists { } } - - private class TestDependencies : OnlinePlayTestSceneDependencies - { - protected override IRoomManager CreateRoomManager() => new TestRoomManager(); - } - - protected class TestRoomManager : IRoomManager - { - public Func? CreateRequested; - - public event Action RoomsUpdated - { - add { } - remove { } - } - - public IBindable InitialRoomsReceived { get; } = new Bindable(true); - - 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) - return; - - string error = CreateRequested.Invoke(room); - - if (!string.IsNullOrEmpty(error)) - onError?.Invoke(error); - else - onSuccess?.Invoke(room); - } - - public void JoinRoom(Room room, string? password, Action? onSuccess = null, Action? onError = null) => throw new NotImplementedException(); - - public void PartRoom() => throw new NotImplementedException(); - } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index c60b208ffc..e1ec30d02a 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs @@ -14,13 +14,15 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsParticipantsList : OnlinePlayTestScene { + private Room room = null!; + public override void SetUpSteps() { base.SetUpSteps(); - AddStep("create list", () => + AddStep("create room", () => { - SelectedRoom.Value = new Room + room = new Room { RoomID = 7, RecentParticipants = Enumerable.Range(0, 50).Select(_ => new APIUser @@ -38,7 +40,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("create component", () => { - Child = new ParticipantsDisplay(SelectedRoom.Value!, Direction.Horizontal) + Child = new ParticipantsDisplay(room, Direction.Horizontal) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -52,7 +54,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("create component", () => { - Child = new ParticipantsDisplay(SelectedRoom.Value!, Direction.Vertical) + Child = new ParticipantsDisplay(room, Direction.Vertical) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 33bd573617..e3137d77d7 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -7,9 +7,12 @@ using System.Linq; using System.Net; using Newtonsoft.Json.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -32,6 +35,9 @@ namespace osu.Game.Tests.Visual.Playlists private const int scores_per_result = 10; private const int real_user_position = 200; + [Cached] + private readonly BeatmapLookupCache beatmapLookupCache = new BeatmapLookupCache(); + private ResultsScreen resultsScreen = null!; private int lowestScoreId; // Score ID of the lowest score in the list. @@ -41,6 +47,11 @@ namespace osu.Game.Tests.Visual.Playlists private int totalCount; private ScoreInfo userScore = null!; + public TestScenePlaylistsResultsScreen() + { + Add(beatmapLookupCache); + } + [SetUpSteps] public override void SetUpSteps() { @@ -58,9 +69,11 @@ namespace osu.Game.Tests.Visual.Playlists totalCount = 0; userScore = TestResources.CreateTestScoreInfo(); + userScore.OnlineID = 1; userScore.TotalScore = 0; userScore.Statistics = new Dictionary(); userScore.MaximumStatistics = new Dictionary(); + userScore.Position = real_user_position; // Beatmap is required to be an actual beatmap so the scores can get their scores correctly // calculated for standardised scoring, else the tests that rely on ordering will fall over. @@ -143,13 +156,13 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); - AddAssert("right loading spinner shown", () => + AddUntilStep("right loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("right loading spinner hidden", () => + AddUntilStep("right loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); } } @@ -167,26 +180,26 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); - AddAssert("right loading spinner shown", () => + AddUntilStep("right loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("right loading spinner hidden", () => + AddUntilStep("right loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("bind delayed handler with no scores", () => bindHandler(delayed: true, noScores: true)); AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); - AddAssert("right loading spinner shown", () => + AddUntilStep("right loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); waitForDisplay(); AddAssert("count not increased", () => this.ChildrenOfType().Count() == beforePanelCount); - AddAssert("right loading spinner hidden", () => + AddUntilStep("right loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); AddAssert("no placeholders shown", () => this.ChildrenOfType().Count(), () => Is.Zero); @@ -209,13 +222,13 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("scroll to left", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToStart(false)); - AddAssert("left loading spinner shown", () => + AddUntilStep("left loading spinner shown", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("left loading spinner hidden", () => + AddUntilStep("left loading spinner hidden", () => resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Hidden); } } @@ -229,7 +242,59 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind user score info handler", () => bindHandler(noScores: true)); createUserBestResults(); AddAssert("no scores visible", () => !resultsScreen.ChildrenOfType().Single().GetScorePanels().Any()); - AddAssert("placeholder shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); + AddUntilStep("placeholder shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); + } + + [Test] + public void TestFetchingAllTheWayToFirstNeverDisplaysNegativePosition() + { + AddStep("set user position", () => userScore.Position = 20); + AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); + + createResultsWithScore(() => userScore); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(true)); + + for (int i = 0; i < 2; i++) + { + AddStep("simulate user falling down ranking", () => userScore.Position += 2); + AddStep("scroll to left", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToStart(false)); + + AddUntilStep("left loading spinner shown", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Visible); + + waitForDisplay(); + + AddUntilStep("left loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Hidden); + } + + AddAssert("total count is 34", () => this.ChildrenOfType().Count(), () => Is.EqualTo(34)); + AddUntilStep("all panels have non-negative position", () => this.ChildrenOfType().All(p => p.ScorePosition.Value > 0)); + } + + [Test] + public void TestPresentInvalidOnlineScore() + { + AddStep("set up invalid user score", () => + { + userScore.OnlineID = -1; + userScore.TotalScore = 0; + }); + + AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); + + createResultsWithScore(() => userScore); + + AddUntilStep("wait for user score to be displayed", () => resultsScreen.ChildrenOfType().Single().GetScorePanels().Any()); + AddWaitStep("wait for any more potential scores", 5); + AddAssert("only 1 score visible", () => resultsScreen.ChildrenOfType().Single().GetScorePanels().Count(), () => Is.EqualTo(1)); + + AddUntilStep("left loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Hidden); + AddUntilStep("right loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); } private void createResultsWithScore(Func getScore) @@ -279,6 +344,25 @@ namespace osu.Game.Tests.Visual.Playlists case IndexPlaylistScoresRequest: break; + case GetBeatmapsRequest getBeatmaps: + getBeatmaps.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap + { + OnlineID = id, + StarRating = id, + DifficultyName = $"Beatmap {id}", + BeatmapSet = new APIBeatmapSet + { + Title = $"Title {id}", + Artist = $"Artist {id}", + AuthorString = $"Author {id}" + } + }).ToList() + }); + + return true; + default: return false; } @@ -298,23 +382,23 @@ namespace osu.Game.Tests.Visual.Playlists switch (request) { case ShowPlaylistScoreRequest s: - if (userScore == null) + if (userScore == null || userScore.OnlineID == -1) triggerFail(s); else - triggerSuccess(s, createUserResponse(userScore)); + triggerSuccess(s, () => createUserResponse(userScore)); break; case ShowPlaylistUserScoreRequest u: - if (userScore == null) + if (userScore == null || userScore.OnlineID == -1) triggerFail(u); else - triggerSuccess(u, createUserResponse(userScore)); + triggerSuccess(u, () => createUserResponse(userScore)); break; case IndexPlaylistScoresRequest i: - triggerSuccess(i, createIndexResponse(i, noScores)); + triggerSuccess(i, () => createIndexResponse(i, noScores)); break; } }, delay); @@ -322,11 +406,11 @@ namespace osu.Game.Tests.Visual.Playlists return true; }; - private void triggerSuccess(APIRequest req, T result) + private void triggerSuccess(APIRequest req, Func result) where T : class { requestComplete = true; - req.TriggerSuccess(result); + req.TriggerSuccess(result.Invoke()); } private void triggerFail(APIRequest req) @@ -337,66 +421,74 @@ namespace osu.Game.Tests.Visual.Playlists private MultiplayerScore createUserResponse(ScoreInfo userScore) { - var multiplayerUserScore = new MultiplayerScore - { - ID = highestScoreId, - Accuracy = userScore.Accuracy, - Passed = userScore.Passed, - Rank = userScore.Rank, - Position = real_user_position, - MaxCombo = userScore.MaxCombo, - User = userScore.User, - ScoresAround = new MultiplayerScoresAround - { - Higher = new MultiplayerScores(), - Lower = new MultiplayerScores() - } - }; + var multiplayerUserScore = createMultiplayerUserScore(userScore); totalCount++; for (int i = 1; i <= scores_per_result; i++) { - multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore + multiplayerUserScore.ScoresAround!.Lower!.Scores.Add(new MultiplayerScore { ID = getNextLowestScoreId(), Accuracy = userScore.Accuracy, Passed = true, Rank = userScore.Rank, MaxCombo = userScore.MaxCombo, + BeatmapId = RNG.Next(0, 7), User = new APIUser { Id = 2 + i, Username = $"peppy{i}", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, }); - multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore + multiplayerUserScore.ScoresAround!.Higher!.Scores.Add(new MultiplayerScore { ID = getNextHighestScoreId(), Accuracy = userScore.Accuracy, Passed = true, Rank = userScore.Rank, MaxCombo = userScore.MaxCombo, + BeatmapId = RNG.Next(0, 7), User = new APIUser { Id = 2 + i, Username = $"peppy{i}", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, }); totalCount += 2; } - addCursor(multiplayerUserScore.ScoresAround.Lower); - addCursor(multiplayerUserScore.ScoresAround.Higher); + addCursor(multiplayerUserScore.ScoresAround!.Lower!); + addCursor(multiplayerUserScore.ScoresAround!.Higher!); return multiplayerUserScore; } - private IndexedMultiplayerScores createIndexResponse(IndexPlaylistScoresRequest req, bool noScores = false) + private MultiplayerScore createMultiplayerUserScore(ScoreInfo userScore) + { + return new MultiplayerScore + { + ID = highestScoreId, + Accuracy = userScore.Accuracy, + Passed = userScore.Passed, + Rank = userScore.Rank, + Position = userScore.Position, + MaxCombo = userScore.MaxCombo, + User = userScore.User, + BeatmapId = RNG.Next(0, 7), + ScoresAround = new MultiplayerScoresAround + { + Higher = new MultiplayerScores(), + Lower = new MultiplayerScores() + } + }; + } + + private IndexedMultiplayerScores createIndexResponse(IndexPlaylistScoresRequest req, bool noScores) { var result = new IndexedMultiplayerScores(); @@ -404,27 +496,41 @@ namespace osu.Game.Tests.Visual.Playlists string sort = req.IndexParams?.Properties["sort"].ToObject() ?? "score_desc"; + bool reachedEnd = false; + for (int i = 1; i <= scores_per_result; i++) { + int nextId = sort == "score_asc" ? getNextHighestScoreId() : getNextLowestScoreId(); + + if (userScore.OnlineID - nextId >= userScore.Position) + { + reachedEnd = true; + break; + } + result.Scores.Add(new MultiplayerScore { - ID = sort == "score_asc" ? getNextHighestScoreId() : getNextLowestScoreId(), + ID = nextId, Accuracy = 1, Passed = true, Rank = ScoreRank.X, MaxCombo = 1000, + BeatmapId = RNG.Next(0, 7), User = new APIUser { Id = 2 + i, Username = $"peppy{i}", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, }); totalCount++; } - addCursor(result); + if (!reachedEnd) + addCursor(result); + + result.UserScore = createMultiplayerUserScore(userScore); return result; } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs index 0270840597..44c2e7eb55 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; @@ -32,14 +33,16 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsRoomCreation : OnlinePlayTestScene { + private RulesetStore rulesets = null!; private BeatmapManager manager = null!; private TestPlaylistsRoomSubScreen match = null!; private BeatmapSetInfo importedBeatmap = null!; + private Room room = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -47,11 +50,9 @@ namespace osu.Game.Tests.Visual.Playlists [SetUpSteps] public void SetupSteps() { - AddStep("set room", () => SelectedRoom.Value = new Room()); - importBeatmap(); - AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom.Value!))); + AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(room = new Room()))); AddUntilStep("wait for load", () => match.IsCurrentScreen()); } @@ -119,7 +120,7 @@ namespace osu.Game.Tests.Visual.Playlists ]; }); - AddAssert("first playlist item selected", () => match.SelectedItem.Value == SelectedRoom.Value!.Playlist[0]); + AddAssert("first playlist item selected", () => match.SelectedItem.Value == room.Playlist[0]); } [Test] @@ -177,6 +178,7 @@ namespace osu.Game.Tests.Visual.Playlists RulesetID = new OsuRuleset().RulesetInfo.OnlineID } ]; + room.EndDate = DateTimeOffset.Now.AddHours(1); }); AddAssert("match has default beatmap", () => match.Beatmap.IsDefault); @@ -197,10 +199,9 @@ namespace osu.Game.Tests.Visual.Playlists AddUntilStep("match has correct beatmap", () => realHash == match.Beatmap.Value.BeatmapInfo.MD5Hash); } - private void setupAndCreateRoom(Action room) + private void setupAndCreateRoom(Action setupFunc) { - AddStep("setup room", () => room(SelectedRoom.Value!)); - + AddStep("setup room", () => setupFunc(room)); AddStep("click create button", () => { InputManager.MoveMouseTo(this.ChildrenOfType().Single()); @@ -214,8 +215,21 @@ namespace osu.Game.Tests.Visual.Playlists Debug.Assert(beatmap.BeatmapInfo.BeatmapSet != null); importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)!.Value.Detach(); + Realm.Write(r => + { + foreach (var beatmapInfo in r.All()) + beatmapInfo.OnlineMD5Hash = beatmapInfo.MD5Hash; + }); }); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } + private partial class TestPlaylistsRoomSubScreen : PlaylistsRoomSubScreen { public new Bindable SelectedItem => base.SelectedItem; diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index de84ca680d..87f65111b0 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -2,23 +2,35 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Platform; -using osu.Framework.Screens; -using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Scoring; +using osu.Game.Rulesets.Taiko; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; @@ -27,123 +39,596 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsRoomSubScreen : OnlinePlayTestScene { - private const double track_length = 10000; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - - private BeatmapManager beatmaps = null!; private RulesetStore rulesets = null!; - private BeatmapSetInfo? importedSet; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { + BeatmapStore beatmapStore; + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(new ScoreManager(rulesets, () => beatmaps, LocalStorage, Realm, API)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.Cache(Realm); - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + Add(beatmapStore); - Realm.Write(r => + importedSet = beatmaps.Import(new BeatmapSetInfo { - foreach (var set in r.All()) + OnlineID = TestResources.GetNextTestID(), + Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(), + DateAdded = DateTimeOffset.UtcNow, + Beatmaps = { - foreach (var b in set.Beatmaps) + new BeatmapInfo { - // These will all have a virtual track length of 1000, see WorkingBeatmap.GetVirtualTrack(). - b.Length = track_length - 1000; + OnlineID = 1, + DifficultyName = "Osu 1", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = "11111111", + OnlineMD5Hash = "11111111", + Ruleset = new OsuRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, + }, + new BeatmapInfo + { + OnlineID = 2, + DifficultyName = "Osu 2", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = "22222222", + OnlineMD5Hash = "22222222", + Ruleset = new OsuRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, + }, + new BeatmapInfo + { + OnlineID = 3, + DifficultyName = "Taiko 1", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = "33333333", + OnlineMD5Hash = "33333333", + Ruleset = new TaikoRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, + }, + new BeatmapInfo + { + OnlineID = 4, + DifficultyName = "Taiko 2", + Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + MD5Hash = "44444444", + OnlineMD5Hash = "44444444", + Ruleset = new TaikoRuleset().RulesetInfo, + Metadata = + { + Artist = "Some Artist", + Title = "Some Song", + Author = { Username = "Some Guy" }, + }, } } - }); - - importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + })!.PerformRead(s => s.Detach()); } + /// + /// Tests that the beatmap and ruleset are adjusted to follow the selected item. + /// [Test] - public void TestStatusUpdateOnEnter() + public void TestBeatmapAndRuleset_FollowSelection() { Room room = null!; - PlaylistsRoomSubScreen roomScreen = null!; - AddStep("create room", () => + AddStep("add room", () => { - RoomManager.AddRoom(room = new Room + room = new Room { - Name = @"Test Room", - Host = new APIUser { Username = @"Host" }, - Category = RoomCategory.Normal, - EndDate = DateTimeOffset.Now.AddMinutes(-1) - }); - }); - - AddStep("push screen", () => LoadScreen(roomScreen = new PlaylistsRoomSubScreen(room))); - AddUntilStep("wait for screen load", () => roomScreen.IsCurrentScreen()); - AddAssert("status is still ended", () => roomScreen.Room.Status, Is.TypeOf); - } - - [Test] - public void TestCloseButtonGoesAwayAfterGracePeriod() - { - Room room = null!; - PlaylistsRoomSubScreen roomScreen = null!; - - AddStep("create room", () => - { - RoomManager.AddRoom(room = new Room - { - Name = @"Test Room", - Host = api.LocalUser.Value, - Category = RoomCategory.Normal, - StartDate = DateTimeOffset.Now.AddMinutes(-5).AddSeconds(3), - EndDate = DateTimeOffset.Now.AddMinutes(30) - }); - }); - - AddStep("push screen", () => LoadScreen(roomScreen = new PlaylistsRoomSubScreen(room))); - AddUntilStep("wait for screen load", () => roomScreen.IsCurrentScreen()); - AddAssert("close button present", () => roomScreen.ChildrenOfType().Any()); - AddUntilStep("wait for close button to disappear", () => !roomScreen.ChildrenOfType().Any()); - } - - [TestCase(120_000, true)] // Definitely enough time. - [TestCase(45_000, true)] // Enough time. - [TestCase(35_000, false)] // Not enough time to complete beatmap after lenience. - [TestCase(20_000, false)] // Not enough time. - [TestCase(5_000, false)] // Not enough time to complete beatmap before lenience. - [TestCase(37_500, true, 2)] // Enough time to complete beatmap after mods are applied. - public void TestReadyButtonEnablementPeriod(int offsetMs, bool enabled, double rate = 1) - { - Room room = null!; - PlaylistsRoomSubScreen roomScreen = null!; - - AddStep("create room", () => - { - RoomManager.AddRoom(room = new Room - { - Name = @"Test Room", - Host = api.LocalUser.Value, - Category = RoomCategory.Normal, - StartDate = DateTimeOffset.Now, - EndDate = DateTimeOffset.Now.AddMilliseconds(offsetMs), + RoomID = 1, Playlist = [ - new PlaylistItem(importedSet!.Beatmaps[0]) + // osu! beatmap + new PlaylistItem(importedSet.Beatmaps[0]) { - RequiredMods = rate == 1 - ? [] - : [new APIMod(new OsuModDoubleTime { SpeedChange = { Value = rate } })] + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + // osu! beatmap converted played in taiko + new PlaylistItem(importedSet.Beatmaps[1]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true } ] - }); + }; + + API.Perform(new CreateRoomRequest(room)); }); - AddStep("push screen", () => LoadScreen(roomScreen = new PlaylistsRoomSubScreen(room))); - AddUntilStep("wait for screen load", () => roomScreen.IsCurrentScreen()); - AddUntilStep("ready button enabled", () => roomScreen.ChildrenOfType().SingleOrDefault()?.Enabled.Value, () => Is.EqualTo(enabled)); + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("select first item", () => screen.SelectedItem.Value = room.Playlist[0]); + AddUntilStep("first beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); + AddUntilStep("osu ruleset selected", () => Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("second beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[1])); + AddUntilStep("taiko ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + } + + /// + /// Tests that the beatmap style is reset when the selected item is changed. + /// + [Test] + public void TestBeatmapStyle_Reset_OnSelection() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user beatmap style", () => screen.UserBeatmap.Value = importedSet.Beatmaps[1]); + AddUntilStep("user beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[1])); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user beatmap style reset", () => screen.UserBeatmap.Value == null); + AddUntilStep("second beatmap selected", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0])); + } + + /// + /// Tests that the ruleset style is reset when the selected item is changed and it's no longer valid. + /// + [Test] + public void TestRulesetStyle_Reset_OnSelection_IfNotValid() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset style", () => screen.UserRuleset.Value = new ManiaRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user ruleset style reset", () => screen.UserRuleset.Value == null); + AddUntilStep("second ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + } + + /// + /// Tests that the ruleset style is preserved when the selected item is changed and the ruleset is still valid. + /// + [Test] + public void TestRulesetStyle_Preserved_OnSelection_IfStillValid() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset style", () => screen.UserRuleset.Value = new ManiaRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user ruleset style preserved", () => screen.UserRuleset.Value!.Equals(new ManiaRuleset().RulesetInfo)); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + } + + /// + /// Tests that mod style is reset when the selected item is changed to another with an inconvertible ruleset. + /// No user style is assumed. + /// + [Test] + public void TestModsReset_OnSelection_DifferentRuleset_NoUserStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user mods", () => screen.UserMods.Value = [new OsuModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style reset", () => !screen.UserMods.Value.Any()); + AddUntilStep("mods reset", () => !SelectedMods.Value.Any()); + } + + /// + /// Tests that mod style is preserved when the selected item is changed to another with the same ruleset. + /// No user style is assumed. + /// + [Test] + public void TestModsPreserved_OnSelection_SameRuleset_NoUserStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true, + AllowedMods = [new APIMod(new OsuModDoubleTime())] + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true, + AllowedMods = [new APIMod(new OsuModDoubleTime())] + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user mods", () => screen.UserMods.Value = [new OsuModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style preserved", () => screen.UserMods.Value.OfType().Any()); + AddUntilStep("mods preserved", () => SelectedMods.Value.OfType().Any()); + } + + /// + /// Tests that mod style is reset when the selected item is changed to another with an inconvertible ruleset. + /// A user beatmap/ruleset style is assumed. + /// + [Test] + public void TestModsReset_OnSelection_DifferentRuleset_WithUserStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset", () => screen.UserRuleset.Value = new CatchRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new CatchRuleset().RulesetInfo)); + AddStep("set user mods", () => screen.UserMods.Value = [new CatchModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style reset", () => !screen.UserMods.Value.Any()); + AddUntilStep("mods reset", () => !SelectedMods.Value.Any()); + } + + /// + /// Tests that mod style is preserved when the selected item is changed to another with the same ruleset. + /// A user beatmap/ruleset style is assumed. + /// + [Test] + public void TestModsPreserved_OnSelection_SameRuleset_WithStyle() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true, + AllowedMods = [new APIMod(new OsuModDoubleTime())] + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + Freestyle = true, + AllowedMods = [new APIMod(new TaikoModDoubleTime())] + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user ruleset", () => screen.UserRuleset.Value = new TaikoRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + AddStep("set user mods", () => screen.UserMods.Value = [new TaikoModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("select second item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mod style preserved", () => screen.UserMods.Value.OfType().Any()); + AddUntilStep("mods preserved", () => SelectedMods.Value.OfType().Any()); + } + + /// + /// Tests that the mod style is revalidated when the ruleset style is changed. + /// + [Test] + public void TestModsValidated_OnRulesetStyleChanged() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + AddStep("set user mods", () => screen.UserMods.Value = [new OsuModDoubleTime()]); + AddUntilStep("user mods selected", () => SelectedMods.Value.OfType().Any()); + + AddStep("set user ruleset", () => screen.UserRuleset.Value = new TaikoRuleset().RulesetInfo); + AddUntilStep("user ruleset selected", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + AddUntilStep("user mods reset", () => !screen.UserMods.Value.Any()); + AddUntilStep("mods reset", () => !SelectedMods.Value.Any()); + } + + /// + /// Tests that the beatmap and ruleset style are reset when the selected item is changed to one without freestyle, + /// and that the mod selection is re-validated against the item's allowed mods. + /// + [Test] + public void TestUserStyle_Reset_OnFreestyleDisabled() + { + Room room = null!; + + AddStep("add room", () => + { + room = new Room + { + RoomID = 1, + Playlist = + [ + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Freestyle = true + }, + new PlaylistItem(importedSet.Beatmaps[0]) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + AllowedMods = [new APIMod(new OsuModDoubleTime())] + }, + ] + }; + + API.Perform(new CreateRoomRequest(room)); + }); + + TestPlaylistsRoomSubScreen screen = null!; + AddStep("load screen", () => LoadScreen(new TestPlaylistsScreen(screen = new TestPlaylistsRoomSubScreen(room)))); + AddUntilStep("wait for load", () => screen.IsLoaded); + + // Set beatmap + ruleset, reset by selecting second playlist item + AddStep("set user beatmap/ruleset style", () => + { + screen.UserBeatmap.Value = importedSet.Beatmaps[1]; + screen.UserRuleset.Value = new TaikoRuleset().RulesetInfo; + }); + AddUntilStep("beatmap/ruleset set", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[1]) && Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); + AddStep("select second playlist item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user style reset", () => screen.UserBeatmap.Value == null && screen.UserRuleset.Value == null); + AddUntilStep("beatmap/ruleset set", () => Beatmap.Value.BeatmapInfo.Equals(importedSet.Beatmaps[0]) && Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("select first playlist item", () => screen.SelectedItem.Value = room.Playlist[0]); + + // Set mods (DT+HR), validate by selecting second playlist item where only DT is allowed. + AddStep("set user mods style", () => screen.UserMods.Value = [new OsuModDoubleTime(), new OsuModHardRock()]); + AddUntilStep("mods set", () => SelectedMods.Value.OfType().Any() && SelectedMods.Value.OfType().Any()); + AddStep("select second playlist item", () => screen.SelectedItem.Value = room.Playlist[1]); + AddUntilStep("user mods validated", () => screen.UserMods.Value.Count == 1 && screen.UserMods.Value.OfType().Any()); + AddUntilStep("mods set", () => SelectedMods.Value.Count == 1 && SelectedMods.Value.OfType().Any()); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } + + private partial class TestPlaylistsScreen : OsuScreen + { + public TestPlaylistsScreen(PlaylistsRoomSubScreen screen) + { + OnlinePlaySubScreenStack stack; + + InternalChildren = new Drawable[] + { + stack = new OnlinePlaySubScreenStack + { + RelativeSizeAxes = Axes.Both + }, + new BackButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { Value = Visibility.Visible }, + Action = () => + { + if (stack.CurrentScreen is not PlaylistsRoomSubScreen) + stack.Exit(); + } + } + }; + + stack.Push(screen); + } + } + + private partial class TestPlaylistsRoomSubScreen : PlaylistsRoomSubScreen + { + public new Bindable SelectedItem => base.SelectedItem; + public new Bindable UserBeatmap => base.UserBeatmap; + public new Bindable UserRuleset => base.UserRuleset; + public new Bindable> UserMods => base.UserMods; + + public TestPlaylistsRoomSubScreen(Room room) + : base(room) + { + } } } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 02a321d22f..eade5aaf5d 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Ranking showPanel(TestResources.CreateTestScoreInfo(beatmap)); }); - AddAssert("pp display faded out", () => + AddUntilStep("pp display faded out", () => { var ppDisplay = this.ChildrenOfType().Single(); return ppDisplay.Alpha == 0.5 && ppDisplay.TooltipText == ResultsScreenStrings.NoPPForUnrankedBeatmaps; @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Visual.Ranking showPanel(score); }); - AddAssert("pp display faded out", () => + AddUntilStep("pp display faded out", () => { var ppDisplay = this.ChildrenOfType().Single(); return ppDisplay.Alpha == 0.5 && ppDisplay.TooltipText == ResultsScreenStrings.NoPPForUnrankedMods; @@ -116,7 +116,7 @@ namespace osu.Game.Tests.Visual.Ranking showPanel(score); }); - AddAssert("pp display faded out", () => this.ChildrenOfType().Single().Alpha == 1); + AddUntilStep("pp display faded out", () => this.ChildrenOfType().Single().Alpha == 1); } [Test] diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 760210c370..bb4b785db0 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -168,6 +168,19 @@ namespace osu.Game.Tests.Visual.Ranking }; }); + public static List CreateHitEvents(double offset = 0, int count = 50) + { + var hitEvents = new List(); + + for (int i = 0; i < count; i++) + { + for (int j = 0; j < count; j++) + hitEvents.Add(new HitEvent(offset, 1.0, HitResult.Perfect, placeholder_object, placeholder_object, null)); + } + + return hitEvents; + } + public static List CreateDistributedHitEvents(double centre = 0, double range = 25) { var hitEvents = new List(); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs index b406ea369f..e49d23dd80 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Online; using osu.Game.Scoring; @@ -12,7 +13,7 @@ namespace osu.Game.Tests.Visual.Ranking { public partial class TestSceneOverallRanking : OsuTestScene { - private OverallRanking overallRanking = null!; + private readonly Bindable statisticsUpdate = new Bindable(); [Test] public void TestUpdatePending() @@ -45,6 +46,32 @@ namespace osu.Game.Tests.Visual.Ranking }); } + // cross-reference: `TestSceneToolbarUserButton.TestTransientUserStatisticsDisplay()`, "Test rounding treatment" step. + [Test] + public void TestRoundingTreatment() + { + createDisplay(); + displayUpdate( + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_071.495M + }, + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_072.99M + }); + } + [Test] public void TestAllDecreased() { @@ -104,14 +131,53 @@ namespace osu.Game.Tests.Visual.Ranking displayUpdate(statistics, statistics); } - private void createDisplay() => AddStep("create display", () => Child = overallRanking = new OverallRanking + [Test] + public void TestFromNothing() { - Width = 400, - Anchor = Anchor.Centre, - Origin = Anchor.Centre + createDisplay(); + displayUpdate( + new UserStatistics(), + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_072 + }); + } + + [Test] + public void TestToNothing() + { + createDisplay(); + displayUpdate( + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_072 + }, + new UserStatistics()); + } + + private void createDisplay() => AddStep("create display", () => + { + statisticsUpdate.Value = null; + Child = new OverallRanking(new ScoreInfo()) + { + Width = 400, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + DisplayedUpdate = { BindTarget = statisticsUpdate } + }; }); private void displayUpdate(UserStatistics before, UserStatistics after) => - AddStep("display update", () => overallRanking.StatisticsUpdate.Value = new ScoreBasedUserStatisticsUpdate(new ScoreInfo(), before, after)); + AddStep("display update", () => statisticsUpdate.Value = new ScoreBasedUserStatisticsUpdate(new ScoreInfo(), before, after)); } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index fca1d0f82a..4758b70526 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -4,7 +4,6 @@ #nullable disable using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; @@ -17,7 +16,6 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Osu; @@ -62,12 +60,6 @@ namespace osu.Game.Tests.Visual.Ranking if (beatmapInfo != null) Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); }); - - AddToggleStep("toggle legacy classic skin", v => - { - if (skins != null) - skins.CurrentSkinInfo.Value = v ? skins.DefaultClassicSkin.SkinInfo : skins.CurrentSkinInfo.Default; - }); } [SetUp] @@ -84,6 +76,16 @@ namespace osu.Game.Tests.Visual.Ranking })); } + [Test] + public void TestLegacySkin() + { + AddToggleStep("toggle legacy classic skin", v => + { + if (skins != null) + skins.CurrentSkinInfo.Value = v ? skins.DefaultClassicSkin.SkinInfo : skins.CurrentSkinInfo.Default; + }); + } + private int onlineScoreID = 1; [TestCase(1, ScoreRank.X, 0)] @@ -402,7 +404,7 @@ namespace osu.Game.Tests.Visual.Ranking : base(score) { AllowRetry = true; - ShowUserStatistics = true; + IsLocalPlay = true; } protected override void LoadComplete() @@ -412,21 +414,19 @@ namespace osu.Game.Tests.Visual.Ranking RetryOverlay = InternalChildren.OfType().SingleOrDefault(); } - protected override APIRequest FetchScores(Action> scoresCallback) + protected override Task FetchScores() { - var scores = new List(); + var scores = new ScoreInfo[20]; - for (int i = 0; i < 20; i++) + for (int i = 0; i < scores.Length; i++) { var score = TestResources.CreateTestScoreInfo(); score.TotalScore += 10 - i; score.HasOnlineReplay = true; - scores.Add(score); + scores[i] = score; } - scoresCallback.Invoke(scores); - - return null; + return Task.FromResult(scores); } } @@ -442,27 +442,25 @@ namespace osu.Game.Tests.Visual.Ranking this.fetchWaitTask = fetchWaitTask ?? Task.CompletedTask; } - protected override APIRequest FetchScores(Action> scoresCallback) + protected override Task FetchScores() { - Task.Run(async () => + return Task.Run(async () => { await fetchWaitTask; - var scores = new List(); + var scores = new ScoreInfo[20]; - for (int i = 0; i < 20; i++) + for (int i = 0; i < scores.Length; i++) { var score = TestResources.CreateTestScoreInfo(); score.TotalScore += 10 - i; - scores.Add(score); + scores[i] = score; } - scoresCallback?.Invoke(scores); - Schedule(() => FetchCompleted = true); - }); - return null; + return scores; + }); } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs new file mode 100644 index 0000000000..e75c831a7f --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs @@ -0,0 +1,547 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneSoloResultsScreen : ScreenTestScene + { + private ScoreManager scoreManager = null!; + private RulesetStore rulesetStore = null!; + private BeatmapManager beatmapManager = null!; + + private LeaderboardManager leaderboardManager = null!; + private BeatmapInfo importedBeatmap = null!; + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); + dependencies.Cache(leaderboardManager = new LeaderboardManager()); + + Dependencies.Cache(Realm); + + return dependencies; + } + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("load leaderboard manager", () => LoadComponent(leaderboardManager)); + + AddStep(@"set beatmap", () => + { + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + Realm.Write(r => + { + foreach (var set in r.All()) + set.Status = BeatmapOnlineStatus.Ranked; + + foreach (var b in r.All()) + b.Status = BeatmapOnlineStatus.Ranked; + }); + importedBeatmap = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + }); + AddStep("clear all scores", () => Realm.Write(r => r.RemoveAll())); + } + + [Test] + public void TestLocalLeaderboardWithOfflineScore() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Local, null))); + AddStep("import some local scores", () => + { + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 10_000 * (30 - i); + scoreManager.Import(score); + } + + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + scoreManager.Import(localScore); + localScore = localScore.Detach(); + }); + + AddStep("show results", () => LoadScreen(new SoloResultsScreen(localScore))); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddUntilStep("local score is #16", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(16)); + } + + [Test] + public void TestLocalLeaderboardWithOnlineScore() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Local, null))); + AddStep("import some local scores", () => + { + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.OnlineID = i; + score.TotalScore = 10_000 * (30 - i); + scoreManager.Import(score); + } + + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.OnlineID = 30; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + scoreManager.Import(localScore); + localScore = localScore.Detach(); + }); + + AddStep("show results", () => LoadScreen(new SoloResultsScreen(localScore))); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddUntilStep("local score is #16", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(16)); + } + + [Test] + public void TestOnlineLeaderboardWithLessThan50Scores() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 10_000 * (30 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + getScoresRequest.TriggerSuccess(new APIScoresCollection { Scores = scores }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddUntilStep("local score is #16", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(16)); + } + + [Test] + public void TestOnlineLeaderboardWithLessThan50Scores_UserWasInTop50() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 10_000 * (30 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + scores[^1].ID = 123456; + scores[^1].UserID = API.LocalUser.Value.OnlineID; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = scores[^1], + Position = 30 + } + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddUntilStep("local score is #16", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(16)); + AddAssert("previous user best not shown", () => this.ChildrenOfType().All(p => p.Score.OnlineID != 123456)); + } + + [Test] + public void TestOnlineLeaderboardWithLessThan50Scores_ShowingAnotherUserScore() + { + var scores = new List(); + var soloScores = new List(); + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => + { + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 10_000 * (30 - i); + score.Position = i + 1; + score.User = new APIUser { Id = i }; + score.BeatmapInfo = new BeatmapInfo + { + OnlineID = 123123, + Status = BeatmapOnlineStatus.Ranked, + }; + score.OnlineID = i; + scores.Add(score); + + var soloScore = SoloScoreInfo.ForSubmission(score); + soloScore.ID = (ulong)i; + soloScores.Add(soloScore); + } + + scores[^1].User = API.LocalUser.Value; + soloScores[^1].UserID = API.LocalUser.Value.OnlineID; + + dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = soloScores, + UserScore = new APIScoreWithPosition + { + Score = soloScores[^1], + Position = 30 + } + }); + return true; + } + + return false; + }; + }); + + AddStep("show results", () => LoadScreen(new SoloResultsScreen(scores[0]))); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local user best shown", () => this.ChildrenOfType().Any(p => p.Score.UserID == API.LocalUser.Value.Id)); + } + + [Test] + public void TestOnlineLeaderboardWithLessThan50Scores_UserIsLast() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 300_000 + 10_000 * (30 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + getScoresRequest.TriggerSuccess(new APIScoresCollection { Scores = scores }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddUntilStep("local score is #31", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(31)); + } + + [Test] + public void TestOnlineLeaderboardWithMoreThan50Scores_UserOutsideOfTop50_DidNotBeatOwnBest() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 50; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 500_000 + 10_000 * (50 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + var userBest = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo(importedBeatmap)); + userBest.TotalScore = 50_000; + userBest.ID = 123456; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = userBest, + Position = 133_337, + }, + ScoresCount = 200_000, + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 31_000; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score has no position", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.Null); + AddUntilStep("previous user best shown at same position", () => this.ChildrenOfType().Any(p => p.Score.OnlineID == 123456 && p.ScorePosition.Value == 133_337)); + } + + [Test] + public void TestOnlineLeaderboardWithMoreThan50Scores_UserOutsideOfTop50_BeatOwnBest() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 50; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 500_000 + 10_000 * (50 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + var userBest = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo(importedBeatmap)); + userBest.TotalScore = 50_000; + userBest.ID = 123456; + userBest.UserID = API.LocalUser.Value.OnlineID; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = userBest, + Position = 133_337, + }, + ScoresCount = 200_000, + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score has no position", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.Null); + AddAssert("previous user best not shown", () => this.ChildrenOfType().All(p => p.Score.OnlineID != 123456)); + } + + [Test] + public void TestOnlineLeaderboardWithMoreThan50Scores_UserInTop50() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 50; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 500_000 + 10_000 * (50 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + var userBest = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo(importedBeatmap)); + userBest.TotalScore = 50_000; + userBest.ID = 123456; + userBest.UserID = API.LocalUser.Value.OnlineID; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = userBest, + Position = 133_337, + } + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 651_000; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddUntilStep("local score is #36", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(36)); + AddAssert("previous user best not shown", () => this.ChildrenOfType().All(p => p.Score.OnlineID != 123456)); + } + + [Test] + public void TestOnlineLeaderboardDeduplication() + { + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 50; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 500_000 + 10_000 * (50 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + var userBest = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo(importedBeatmap)); + userBest.TotalScore = 151_000; + userBest.ID = 12345; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = userBest, + Position = 133_337, + }, + ScoresCount = 200_000, + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + var localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.OnlineID = 12345; + localScore.Position = null; + localScore.User = API.LocalUser.Value; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("only one score with ID 12345", () => this.ChildrenOfType().Count(s => s.Score.OnlineID == 12345), () => Is.EqualTo(1)); + AddUntilStep("user best position preserved", () => this.ChildrenOfType().Any(p => p.ScorePosition.Value == 133_337)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesetStore.IsNotNull()) + rulesetStore.Dispose(); + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index c12b9d29bc..88e381a468 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -8,20 +8,32 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Rulesets.Osu.Objects; @@ -36,6 +48,27 @@ namespace osu.Game.Tests.Visual.Ranking { public partial class TestSceneStatisticsPanel : OsuTestScene { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private ScoreManager scoreManager = null!; + private RulesetStore rulesetStore = null!; + private BeatmapManager beatmapManager = null!; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); + Dependencies.Cache(Realm); + + return dependencies; + } + [Test] public void TestScoreWithPositionStatistics() { @@ -137,62 +170,224 @@ namespace osu.Game.Tests.Visual.Ranking { CachedDependencies = [(typeof(UserStatisticsWatcher), userStatisticsWatcher)], RelativeSizeAxes = Axes.Both, - Child = new UserStatisticsPanel(score) + Child = new StatisticsPanel { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible }, - Score = { Value = score, } + Score = { Value = score, }, + AchievedScore = score, } }); AddUntilStep("overall ranking present", () => this.ChildrenOfType().Any()); - AddUntilStep("loading spinner not visible", () => this.ChildrenOfType().All(l => l.State.Value == Visibility.Hidden)); + AddUntilStep("loading spinner not visible", + () => this.ChildrenOfType().Single() + .ChildrenOfType().All(l => l.State.Value == Visibility.Hidden)); + } + + [Test] + public void TestTagging() + { + var score = TestResources.CreateTestScoreInfo(); + + setUpTaggingRequests(() => score.BeatmapInfo); + AddStep("load panel", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + AchievedScore = score, + } + }; + }); + } + + private void setUpTaggingRequests(Func beatmap) => + AddStep("set up network requests", () => + { + dummyAPI.HandleRequest = request => + { + switch (request) + { + case ListTagsRequest listTagsRequest: + { + Scheduler.AddDelayed(() => listTagsRequest.TriggerSuccess(new APITagCollection + { + Tags = + [ + new APITag { Id = 1, Name = "song representation/simple", Description = "Accessible and straightforward map design.", }, + new APITag + { + Id = 2, Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects.", + }, + new APITag + { + Id = 3, Name = "aim/aim control", Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern.", + }, + new APITag { Id = 4, Name = "tap/bursts", Description = "Patterns requiring continuous movement and alternating, typically 9 notes or less.", }, + ] + }), 500); + return true; + } + + case GetBeatmapSetRequest getBeatmapSetRequest: + { + var beatmapSet = CreateAPIBeatmapSet(beatmap.Invoke()); + beatmapSet.Beatmaps.Single().TopTags = + [ + new APIBeatmapTag { TagId = 3, VoteCount = 9 }, + ]; + Scheduler.AddDelayed(() => getBeatmapSetRequest.TriggerSuccess(beatmapSet), 500); + return true; + } + + case AddBeatmapTagRequest: + case RemoveBeatmapTagRequest: + { + Scheduler.AddDelayed(request.TriggerSuccess, 500); + return true; + } + } + + return false; + }; + }); + + [Test] + public void TestTaggingWhenRankTooLow() + { + var score = TestResources.CreateTestScoreInfo(); + score.Rank = ScoreRank.D; + + setUpTaggingRequests(() => score.BeatmapInfo); + AddStep("load panel", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + AchievedScore = score, + } + }; + }); + } + + [Test] + public void TestTaggingConvert() + { + var score = TestResources.CreateTestScoreInfo(); + score.Ruleset = new ManiaRuleset().RulesetInfo; + + setUpTaggingRequests(() => score.BeatmapInfo); + AddStep("load panel", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + AchievedScore = score, + } + }; + }); + } + + [Test] + public void TestTaggingInteractionWithLocalScores() + { + BeatmapInfo beatmapInfo = null!; + + AddStep(@"Import beatmap", () => + { + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + }); + + AddStep("import bad score", () => + { + var score = TestResources.CreateTestScoreInfo(); + score.BeatmapInfo = beatmapInfo; + score.BeatmapHash = beatmapInfo.Hash; + score.Ruleset = beatmapInfo.Ruleset; + score.Rank = ScoreRank.D; + score.User = API.LocalUser.Value; + scoreManager.Import(score); + }); + + AddStep("import score by another user", () => + { + var score = TestResources.CreateTestScoreInfo(); + score.BeatmapInfo = beatmapInfo; + score.BeatmapHash = beatmapInfo.Hash; + score.Ruleset = beatmapInfo.Ruleset; + score.Rank = ScoreRank.D; + score.User = new APIUser { Username = "notme", Id = 5678 }; + scoreManager.Import(score); + }); + + AddStep("import convert score", () => + { + var score = TestResources.CreateTestScoreInfo(); + score.BeatmapInfo = beatmapInfo; + score.BeatmapHash = beatmapInfo.Hash; + score.Ruleset = new OsuRuleset().RulesetInfo; + score.User = API.LocalUser.Value; + scoreManager.Import(score); + }); + + AddStep("import correct score", () => + { + var score = TestResources.CreateTestScoreInfo(); + score.BeatmapInfo = beatmapInfo; + score.BeatmapHash = beatmapInfo.Hash; + score.Ruleset = beatmapInfo.Ruleset; + score.User = API.LocalUser.Value; + scoreManager.Import(score); + }); + + setUpTaggingRequests(() => beatmapInfo); + AddStep("load panel", () => + { + var score = TestResources.CreateTestScoreInfo(); + score.BeatmapInfo = beatmapInfo; + + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + } + }; + }); } private void loadPanel(ScoreInfo score) => AddStep("load panel", () => { - Child = new UserStatisticsPanel(score) + Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - Score = { Value = score }, - DisplayedUserStatisticsUpdate = + Child = new StatisticsPanel { - Value = new ScoreBasedUserStatisticsUpdate(score, new UserStatistics - { - Level = new UserStatistics.LevelInfo - { - Current = 5, - Progress = 20, - }, - GlobalRank = 38000, - CountryRank = 12006, - PP = 2134, - RankedScore = 21123849, - Accuracy = 0.985, - PlayCount = 13375, - PlayTime = 354490, - TotalScore = 128749597, - TotalHits = 0, - MaxCombo = 1233, - }, new UserStatistics - { - Level = new UserStatistics.LevelInfo - { - Current = 5, - Progress = 30, - }, - GlobalRank = 36000, - CountryRank = 12000, - PP = (decimal)2134.5, - RankedScore = 23897015, - Accuracy = 0.984, - PlayCount = 13376, - PlayTime = 35789, - TotalScore = 132218497, - TotalHits = 0, - MaxCombo = 1233, - }) - } + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score }, + AchievedScore = score, + }, }; }); @@ -216,6 +411,14 @@ namespace osu.Game.Tests.Visual.Ranking return hitEvents; } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesetStore.IsNotNull()) + rulesetStore?.Dispose(); + } + private class TestRuleset : Ruleset { public override IEnumerable GetModsFor(ModType type) diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs new file mode 100644 index 0000000000..4f91e355c0 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs @@ -0,0 +1,197 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Game.Screens.Ranking; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneUserTagControl : OsuManualInputManagerTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private int writeRequestCount; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + + AddStep("set up network requests", () => + { + writeRequestCount = 0; + dummyAPI.HandleRequest = request => + { + switch (request) + { + case ListTagsRequest listTagsRequest: + { + Scheduler.AddDelayed(() => listTagsRequest.TriggerSuccess(new APITagCollection + { + Tags = + [ + new APITag { Id = 0, Name = "uncategorised tag", Description = "This probably isn't real but could be and should be handled.", }, + new APITag { Id = 1, Name = "song representation/simple", Description = "Accessible and straightforward map design.", }, + new APITag + { + Id = 2, Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects.", + }, + new APITag + { + Id = 3, Name = "aim/aim control", Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern.", + }, + new APITag { Id = 4, Name = "tap/bursts", Description = "Patterns requiring continuous movement and alternating, typically 9 notes or less.", }, + new APITag { Id = 5, Name = "style/mono-heavy", Description = "Features monos used in large amounts.", RulesetId = 1, }, + ] + }), 500); + return true; + } + + case GetBeatmapSetRequest getBeatmapSetRequest: + { + var beatmapSet = CreateAPIBeatmapSet(Beatmap.Value.BeatmapInfo); + beatmapSet.Beatmaps.Single().TopTags = + [ + new APIBeatmapTag { TagId = 3, VoteCount = 4 }, + new APIBeatmapTag { TagId = 2, VoteCount = 3 }, + new APIBeatmapTag { TagId = 0, VoteCount = 2 }, + ]; + Scheduler.AddDelayed(() => getBeatmapSetRequest.TriggerSuccess(beatmapSet), 500); + return true; + } + + case AddBeatmapTagRequest: + case RemoveBeatmapTagRequest: + { + writeRequestCount++; + Scheduler.AddDelayed(request.TriggerSuccess, 500); + return true; + } + } + + return false; + }; + }); + } + + [Test] + public void TestRulesetSupport() + { + AddStep("show for osu! beatmap", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.BeatmapInfo.OnlineID = 42; + Beatmap.Value = working; + recreateControl(); + }); + + AddStep("show for taiko beatmap", () => + { + var working = CreateWorkingBeatmap(new TaikoRuleset().RulesetInfo); + working.BeatmapInfo.OnlineID = 44; + Beatmap.Value = working; + recreateControl(); + }); + } + + [Test] + public void TestNotWritable() + { + AddStep("show", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.BeatmapInfo.OnlineID = 42; + Beatmap.Value = working; + recreateControl(writable: false); + }); + + AddUntilStep("click tag", () => + { + var tag = this.ChildrenOfType().FirstOrDefault(t => t.UserTag.Id == 2); + if (tag == null) + return false; + + InputManager.MoveMouseTo(tag); + InputManager.Click(MouseButton.Left); + return true; + }); + + AddAssert("no vote requests send", () => writeRequestCount, () => Is.Zero); + } + + [Test] + public void TestTagsDoNotMoveUntilMouseMovesAway() + { + AddStep("show", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.BeatmapInfo.OnlineID = 42; + Beatmap.Value = working; + recreateControl(); + }); + AddUntilStep("wait for ready", () => getTagFlow().Count, () => Is.EqualTo(4)); + AddAssert("tag 2 is second", () => getTagFlow().GetLayoutPosition(getDrawableTagById(2)), () => Is.EqualTo(1)); + AddStep("vote for tag 2", () => + { + InputManager.MoveMouseTo(getDrawableTagById(2)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("tag 2 voted for", () => getDrawableTagById(2).UserTag.VoteCount.Value, () => Is.EqualTo(4)); + + AddStep("remove vote for tag 2", () => + { + InputManager.MoveMouseTo(getDrawableTagById(2)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("tag 2 not voted for", () => getDrawableTagById(2).UserTag.VoteCount.Value, () => Is.EqualTo(3)); + AddAssert("tag 2 is still second", () => getTagFlow().GetLayoutPosition(getDrawableTagById(2)), () => Is.EqualTo(1)); + + AddStep("vote for tag 2", () => + { + InputManager.MoveMouseTo(getDrawableTagById(2)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("tag 2 voted for", () => getDrawableTagById(2).UserTag.VoteCount.Value, () => Is.EqualTo(4)); + AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("tag 2 reordered to first", () => getTagFlow().GetLayoutPosition(getDrawableTagById(2)), () => Is.EqualTo(0)); + + FillFlowContainer getTagFlow() => this.ChildrenOfType>().Single(); + + UserTagControl.DrawableUserTag getDrawableTagById(long id) => getTagFlow().Single(t => t.UserTag.Id == id); + } + + private void recreateControl(bool writable = true) + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new UserTagControl(Beatmap.Value.BeatmapInfo) + { + Writable = writable, + Width = 700, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs index 85cde966b1..2fc5378ba1 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneAudioOffsetAdjustControl.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.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.Graphics.UserInterface; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Overlays.Settings.Sections.Audio; @@ -70,16 +73,54 @@ namespace osu.Game.Tests.Visual.Settings AddStep("clear history", () => tracker.ClearHistory()); } + [Test] + public void TestRounding() + { + AddStep("set new score", () => statics.SetValue(Static.LastLocalUserScore, new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateHitEvents(0.6), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + })); + + checkButtonEnabled(); + AddStep("click button", () => adjustControl.ChildrenOfType public partial class TestMultiplayerComponents : OsuScreen { - public Screens.OnlinePlay.Multiplayer.Multiplayer MultiplayerScreen => multiplayerScreen; - - public TestMultiplayerRoomManager RoomManager => multiplayerScreen.RoomManager; + public Screens.OnlinePlay.Multiplayer.Multiplayer MultiplayerScreen { get; } public IScreen CurrentScreen => screenStack.CurrentScreen; @@ -53,17 +49,17 @@ namespace osu.Game.Tests.Visual private BeatmapManager beatmapManager { get; set; } private readonly OsuScreenStack screenStack; - private readonly TestMultiplayer multiplayerScreen; + private readonly TestRoomRequestsHandler requestsHandler = new TestRoomRequestsHandler(); public TestMultiplayerComponents() { - multiplayerScreen = new TestMultiplayer(); + MultiplayerScreen = new Screens.OnlinePlay.Multiplayer.Multiplayer(); InternalChildren = new Drawable[] { userLookupCache, beatmapLookupCache, - MultiplayerClient = new TestMultiplayerClient(RoomManager), + MultiplayerClient = new TestMultiplayerClient(requestsHandler), screenStack = new OsuScreenStack { Name = nameof(TestMultiplayerComponents), @@ -71,13 +67,13 @@ namespace osu.Game.Tests.Visual } }; - screenStack.Push(multiplayerScreen); + screenStack.Push(MultiplayerScreen); } [BackgroundDependencyLoader] private void load(IAPIProvider api) { - ((DummyAPIAccess)api).HandleRequest = request => multiplayerScreen.RequestsHandler.HandleRequest(request, api.LocalUser.Value, beatmapManager); + ((DummyAPIAccess)api).HandleRequest = request => requestsHandler.HandleRequest(request, api.LocalUser.Value, beatmapManager); } public override bool OnBackButton() => (screenStack.CurrentScreen as OsuScreen)?.OnBackButton() ?? base.OnBackButton(); @@ -90,13 +86,5 @@ namespace osu.Game.Tests.Visual screenStack.Exit(); return true; } - - private partial class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer - { - public new TestMultiplayerRoomManager RoomManager { get; private set; } - public TestRoomRequestsHandler RequestsHandler { get; private set; } - - protected override RoomManager CreateRoomManager() => RoomManager = new TestMultiplayerRoomManager(RequestsHandler = new TestRoomRequestsHandler()); - } } } diff --git a/osu.Game.Tests/Visual/TestSceneReplayStability.cs b/osu.Game.Tests/Visual/TestSceneReplayStability.cs new file mode 100644 index 0000000000..749493c4b1 --- /dev/null +++ b/osu.Game.Tests/Visual/TestSceneReplayStability.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.Linq; +using NUnit.Framework; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Tests.Visual +{ + public partial class TestSceneReplayStability : ReplayStabilityTestScene + { + [Test] + public void TestOutrageouslyLargeLeadInTime() + { + // "graciously borrowed" from https://osu.ppy.sh/beatmapsets/948643#osu/1981090 + const double lead_in_time = 2147272727; + const double hit_circle_time = 100; + + var beatmap = new OsuBeatmap + { + HitObjects = + { + new HitCircle + { + StartTime = hit_circle_time, + Position = OsuPlayfield.BASE_SIZE / 2 + } + }, + AudioLeadIn = lead_in_time, + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo, + }, + }; + + var replay = new Replay + { + Frames = Enumerable.Range(0, 300).Select(t => new OsuReplayFrame(-lead_in_time + 40 * t, new Vector2(t), t % 2 == 0 ? [] : [OsuAction.LeftButton])) + .Concat([ + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + 20, OsuPlayfield.BASE_SIZE / 2), + ]) + .Cast() + .ToList(), + }; + + RunTest(beatmap, replay, [HitResult.Great]); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs index 7aaf616767..ba3f2f637c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs @@ -13,9 +13,10 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneBackButton : OsuTestScene { + private readonly BackButton? button; + public TestSceneBackButton() { - BackButton button; ScreenFooter.BackReceptor receptor = new ScreenFooter.BackReceptor(); Child = new Container @@ -34,14 +35,13 @@ namespace osu.Game.Tests.Visual.UserInterface }, button = new BackButton(receptor) { + Action = () => button?.Hide(), Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, } } }; - button.Action = () => button.Hide(); - AddStep("show button", () => button.Show()); AddStep("hide button", () => button.Hide()); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs index d8baca6d23..ba5cc56f34 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs @@ -68,14 +68,15 @@ namespace osu.Game.Tests.Visual.UserInterface } [TestCase(Key.P, Key.P)] - [TestCase(Key.M, Key.P)] - [TestCase(Key.L, Key.P)] - [TestCase(Key.B, Key.E)] - [TestCase(Key.S, Key.E)] - [TestCase(Key.D, null)] - [TestCase(Key.Q, null)] - [TestCase(Key.O, null)] - public void TestShortcutKeys(Key key, Key? subMenuEnterKey) + [TestCase(Key.M, Key.M, Key.L)] + [TestCase(Key.M, Key.M, Key.M)] + [TestCase(Key.L, Key.L)] + [TestCase(Key.B, Key.E, Key.B)] + [TestCase(Key.S, Key.E, Key.S)] + [TestCase(Key.D)] + [TestCase(Key.Q)] + [TestCase(Key.O)] + public void TestShortcutKeys(params Key[] keys) { int activationCount = -1; AddStep("set up action", () => @@ -83,7 +84,7 @@ namespace osu.Game.Tests.Visual.UserInterface activationCount = 0; void action() => activationCount++; - switch (key) + switch (keys.First()) { case Key.P: buttons.OnSolo = action; @@ -119,16 +120,19 @@ namespace osu.Game.Tests.Visual.UserInterface } }); - AddStep($"press {key}", () => InputManager.Key(key)); + // trigger out of idle state + AddStep($"press {keys.First()}", () => InputManager.Key(keys.First())); AddAssert("state is top level", () => buttons.State == ButtonSystemState.TopLevel); - if (subMenuEnterKey != null) + for (int i = 0; i < keys.Length; i++) { - AddStep($"press {subMenuEnterKey}", () => InputManager.Key(subMenuEnterKey.Value)); - AddAssert("state is not top menu", () => buttons.State != ButtonSystemState.TopLevel); + var key = keys[i]; + AddStep($"press {key}", () => InputManager.Key(key)); + + if (i > 0) + AddAssert("state is not top menu", () => buttons.State != ButtonSystemState.TopLevel); } - AddStep($"press {key}", () => InputManager.Key(key)); AddAssert("action triggered", () => activationCount == 1); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index f7bdda6b57..c2277f2c7c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -9,6 +9,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Platform; @@ -37,6 +38,7 @@ namespace osu.Game.Tests.Visual.UserInterface private readonly ContextMenuContainer contextMenuContainer; private readonly BeatmapLeaderboard leaderboard; + private RulesetStore rulesets = null!; private BeatmapManager beatmapManager; private ScoreManager scoreManager; @@ -71,7 +73,7 @@ namespace osu.Game.Tests.Visual.UserInterface { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.Cache(new RealmRulesetStore(Realm)); + dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get(), () => beatmapManager, LocalStorage, Realm, API)); Dependencies.Cache(Realm); @@ -151,7 +153,8 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("click delete option", () => { - InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => string.Equals(i.Item.Text.Value.ToString(), "delete", System.StringComparison.OrdinalIgnoreCase))); + InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType() + .First(i => string.Equals(i.Item.Text.Value.ToString(), "delete", System.StringComparison.OrdinalIgnoreCase))); InputManager.Click(MouseButton.Left); }); @@ -178,5 +181,13 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for fetch", () => leaderboard.Scores.Any()); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != importedScores[0].OnlineID)); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs index b590abf4e5..e78b4d2496 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs @@ -2,9 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics; using osuTK; using osuTK.Graphics; @@ -13,25 +16,35 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneDrawableDate : OsuTestScene { - public TestSceneDrawableDate() + [SetUpSteps] + public void SetUpSteps() { - Child = new FillFlowContainer + AddStep("Create 7 dates", () => { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Children = new Drawable[] + Child = new FillFlowContainer { - new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(60))), - new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(55))), - new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(50))), - new PokeyDrawableDate(DateTimeOffset.Now), - new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(60))), - new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(65))), - new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(70))), - } - }; + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Children = new Drawable[] + { + new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(60))), + new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(55))), + new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(50))), + new PokeyDrawableDate(DateTimeOffset.Now), + new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(60))), + new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(65))), + new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(70))), + } + }; + }); + } + + [Test] + public void TestSecondsUpdate() + { + AddUntilStep("4th date says \"2 seconds ago\"", () => this.ChildrenOfType().ElementAt(3).Current.Value == "2 seconds ago"); } private partial class PokeyDrawableDate : CompositeDrawable diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs index a91e6e3350..f38fa05218 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -14,6 +16,9 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneFPSCounter : OsuTestScene { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + [SetUpSteps] public void SetUpSteps() { @@ -41,6 +46,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, }; }); + AddToggleStep("toggle show", b => config.SetValue(OsuSetting.ShowFpsDisplay, b)); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs index 2dee57f4cb..4d180f6507 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs @@ -3,8 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Screens; +using osu.Game.Database; using osu.Game.Overlays; using osu.Game.Overlays.FirstRunSetup; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.UserInterface { @@ -13,6 +15,9 @@ namespace osu.Game.Tests.Visual.UserInterface [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + [Cached(typeof(BeatmapStore))] + private BeatmapStore beatmapStore = new TestBeatmapStore(); + public TestSceneFirstRunScreenUIScale() { AddStep("load screen", () => diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index 2ca06bf2f4..dc51e5516a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -17,12 +17,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.FirstRunSetup; using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osu.Game.Screens.Footer; +using osu.Game.Tests.Beatmaps; using osuTK; using osuTK.Input; @@ -47,6 +49,7 @@ namespace osu.Game.Tests.Visual.UserInterface Dependencies.Cache(LocalConfig = new OsuConfigManager(LocalStorage)); Dependencies.CacheAs(performer.Object); Dependencies.CacheAs(notificationOverlay.Object); + Dependencies.CacheAs(new TestBeatmapStore()); } [SetUpSteps] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs index c6fd65b973..2003f5de83 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs @@ -6,9 +6,11 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Screens.Edit.Setup; using osuTK; namespace osu.Game.Tests.Visual.UserInterface @@ -26,82 +28,102 @@ namespace osu.Game.Tests.Visual.UserInterface Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer + Child = new OsuScrollContainer { - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 400, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5), - Padding = new MarginPadding(10), - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer { - new FormTextBox + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - TabbableContentContainer = this, - }, - new FormTextBox - { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - Current = { Disabled = true }, - TabbableContentContainer = this, - }, - new FormNumberBox - { - Caption = "Number", - HintText = "Insert your favourite number", - PlaceholderText = "Mine is 42!", - TabbableContentContainer = this, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - Current = { Disabled = true }, - }, - new FormSliderBar - { - Caption = "Slider", - Current = new BindableFloat + new FormTextBox { - MinValue = 0, - MaxValue = 10, - Value = 5, - Precision = 0.1f, + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + TabbableContentContainer = this, }, - TabbableContentContainer = this, - }, - new FormEnumDropdown - { - Caption = EditorSetupStrings.EnableCountdown, - HintText = EditorSetupStrings.CountdownDescription, - }, - new FormFileSelector - { - Caption = "Audio file", - PlaceholderText = "Select an audio file", - }, - new FormColourPalette - { - Caption = "Combo colours", - Colours = + new FormTextBox { - Colour4.Red, - Colour4.Green, - Colour4.Blue, - Colour4.Yellow, - } + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + Current = { Disabled = true }, + TabbableContentContainer = this, + }, + new FormNumberBox(allowDecimals: true) + { + Caption = "Number", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + TabbableContentContainer = this, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Disabled = true }, + }, + new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + TabbableContentContainer = this, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormFileSelector + { + Caption = "File selector", + PlaceholderText = "Select a file", + }, + new FormBeatmapFileSelector(true) + { + Caption = "File selector with intermediate choice dialog", + PlaceholderText = "Select a file", + }, + new FormColourPalette + { + Caption = "Combo colours", + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, + new FormButton + { + Caption = "No text in button", + Action = () => { }, + }, + new FormButton + { + Caption = "Text in button which is pretty long and is very likely to wrap", + ButtonText = "Foo the bar", + Action = () => { }, + }, }, }, }, diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs index c75c2a7877..899e6077cd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs @@ -1,16 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; using osu.Game.Overlays; using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Users; namespace osu.Game.Tests.Visual.UserInterface { @@ -19,37 +20,90 @@ namespace osu.Game.Tests.Visual.UserInterface [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - private FriendOnlineStreamControl control; + private TestMetadataClient metadataClient = null!; [SetUp] - public void SetUp() => Schedule(() => Child = control = new FriendOnlineStreamControl + public void SetUp() => Schedule(() => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(MetadataClient), metadataClient = new TestMetadataClient()) + ], + Children = new Drawable[] + { + metadataClient, + new FriendOnlineStreamControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; }); [Test] - public void Populate() + public void TestChangeFriends() { - AddStep("Populate", () => control.Populate(new List + AddStep("set 10 friends", () => { - new APIUser + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.AddRange(Enumerable.Range(1, 10).Select(i => new APIRelation { - IsOnline = true - }, - new APIUser - { - IsOnline = false - }, - new APIUser - { - IsOnline = false - } - })); + RelationType = RelationType.Friend, + TargetID = i, + TargetUser = new APIUser { Id = i }, + })); + }); - AddAssert("3 users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.All)?.Count == 3); - AddAssert("1 online user", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Online)?.Count == 1); - AddAssert("2 offline users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Offline)?.Count == 2); + AddStep("set 20 friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.AddRange(Enumerable.Range(1, 20).Select(i => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = i, + TargetUser = new APIUser { Id = i }, + })); + }); + } + + [Test] + public void TestChangeOnlineStates() + { + AddStep("set 10 friends", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.LocalUserState.Friends.Clear(); + api.LocalUserState.Friends.AddRange(Enumerable.Range(1, 10).Select(i => new APIRelation + { + RelationType = RelationType.Friend, + TargetID = i, + TargetUser = new APIUser { Id = i }, + })); + }); + + AddStep("make users 1-5 online", () => + { + for (int i = 1; i <= 5; i++) + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + }); + + AddStep("make users 1-5 DnD", () => + { + for (int i = 1; i <= 5; i++) + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.DoNotDisturb }); + }); + + AddStep("make users 1-5 offline", () => + { + for (int i = 1; i <= 5; i++) + metadataClient.FriendPresenceUpdated(i, null); + }); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneGhostIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneGhostIcon.cs new file mode 100644 index 0000000000..5ae46d0224 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneGhostIcon.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneGhostIcon : OsuTestScene + { + public TestSceneGhostIcon() + { + Add(new GhostIcon + { + Size = new Vector2(64), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs index dc40ecde43..66bf870f90 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs @@ -67,11 +67,11 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("show", () => overlay.Show()); - AddUntilStep("wait for content dim", () => overlay.BackgroundDimLayer.Alpha > 0); + AddUntilStep("wait for content dim", () => overlay.Alpha > 0); AddStep("hide", () => overlay.Hide()); - AddUntilStep("wait for content restore", () => Precision.AlmostEquals(overlay.BackgroundDimLayer.Alpha, 0)); + AddUntilStep("wait for content restore", () => Precision.AlmostEquals(overlay.Alpha, 0)); } [Test] @@ -90,8 +90,6 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class TestLoadingLayer : LoadingLayer { - public new Box BackgroundDimLayer => base.BackgroundDimLayer; - public TestLoadingLayer(bool dimBackground = false, bool withBox = true) : base(dimBackground, withBox) { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs index bd36be846b..b3d943b93d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs @@ -11,7 +11,7 @@ namespace osu.Game.Tests.Visual.UserInterface public partial class TestSceneLoadingSpinner : OsuGridTestScene { public TestSceneLoadingSpinner() - : base(2, 2) + : base(2, 3) { LoadingSpinner loading; @@ -52,6 +52,29 @@ namespace osu.Game.Tests.Visual.UserInterface loading.Show(); Cell(3).AddRange(new Drawable[] + { + new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both + }, + loading = new LoadingSpinner(false, true) + }); + + loading.Show(); + + Cell(4).AddRange(new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both + }, + loading = new LoadingSpinner(true, true) + }); + loading.Show(); + + Cell(5).AddRange(new Drawable[] { loading = new LoadingSpinner() }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs index 8d5c961265..931b5afa12 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs @@ -5,6 +5,7 @@ using System; using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -28,6 +29,9 @@ namespace osu.Game.Tests.Visual.UserInterface private Drawable logoFacade; private bool randomPositions; + [CanBeNull] + private IDisposable logoTracking; + private const float visual_box_size = 72; [SetUpSteps] @@ -150,14 +154,15 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("Perform logo movements", () => { - trackingContainer.StopTracking(); + logoTracking?.Dispose(); + logo.MoveTo(new Vector2(0.5f), 500, Easing.InOutExpo); visualBox.Colour = Color4.White; Scheduler.AddDelayed(() => { - trackingContainer.StartTracking(logo, 1000, Easing.InOutExpo); + logoTracking = trackingContainer.StartTracking(logo, 1000, Easing.InOutExpo); visualBox.Colour = Color4.Tomato; }, 700); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs index c091c089cf..86659675a0 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs @@ -13,8 +13,8 @@ using osu.Game.Online.Metadata; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.Menu; +using osuTK.Graphics; using osuTK.Input; -using Color4 = osuTK.Graphics.Color4; namespace osu.Game.Tests.Visual.UserInterface { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs index f5506edf3b..b7d58a633d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.UserInterface AutoSizeAxes = Axes.Y, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), new ImportScoreTest.TestArchiveReader(), "deadbeef") + Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), "deadbeef", new ImportScoreTest.TestArchiveReader()) }; } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs index b40d0b10d2..30470c9c17 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs @@ -18,6 +18,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { @@ -220,6 +221,29 @@ namespace osu.Game.Tests.Visual.UserInterface checkBindableAtValue("Circle Size", null); } + [Test] + public void TestResetToDefaultViaDoubleClickingNub() + { + setBeatmapWithDifficultyParameters(5); + + setSliderValue("Circle Size", 3); + setExtendedLimits(true); + + checkSliderAtValue("Circle Size", 3); + checkBindableAtValue("Circle Size", 3); + + AddStep("double click circle size nub", () => + { + var nub = this.ChildrenOfType.SliderNub>().First(); + InputManager.MoveMouseTo(nub); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + + checkSliderAtValue("Circle Size", 5); + checkBindableAtValue("Circle Size", null); + } + [Test] public void TestModSettingChangeTracker() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModEffectPreviewPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModEffectPreviewPanel.cs index cdb6900f06..5bb590a247 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModEffectPreviewPanel.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModEffectPreviewPanel.cs @@ -125,7 +125,7 @@ namespace osu.Game.Tests.Visual.UserInterface public StarDifficulty? Difficulty { get; set; } public override Task GetDifficultyAsync(IBeatmapInfo beatmapInfo, IRulesetInfo? rulesetInfo = null, IEnumerable? mods = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, int computationDelay = 0) => Task.FromResult(Difficulty); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs index 11cd122c99..d554ac7424 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -4,31 +4,81 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneModIcon : OsuTestScene + public partial class TestSceneModIcon : OsuManualInputManagerTestScene { + private FillFlowContainer spreadOutFlow = null!; + private ModDisplay modDisplay = null!; + + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create flows", () => + { + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.5f), + new Dimension(GridSizeMode.Relative, 0.5f), + }, + Content = new[] + { + new Drawable[] + { + modDisplay = new ModDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + new Drawable[] + { + spreadOutFlow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + } + } + } + }; + }); + } + + private void addRange(IEnumerable mods) + { + spreadOutFlow.AddRange(mods.Select(m => new ModIcon(m))); + modDisplay.Current.Value = modDisplay.Current.Value.Concat(mods.OfType()).ToList(); + } + [Test] public void TestShowAllMods() { - AddStep("create mod icons", () => - { - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Full, - ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods().Select(m => new ModIcon(m)), - }; - }); + createModIconsForRuleset(0); + createModIconsForRuleset(1); + createModIconsForRuleset(2); + createModIconsForRuleset(3); AddStep("toggle selected", () => { @@ -37,31 +87,53 @@ namespace osu.Game.Tests.Visual.UserInterface }); } + private void createModIconsForRuleset(int rulesetId) + { + AddStep($"create mod icons for ruleset {rulesetId}", () => + { + spreadOutFlow.Clear(); + modDisplay.Current.Value = []; + + addRange(rulesetStore.GetRuleset(rulesetId)!.CreateInstance().CreateAllMods().Select(m => + { + if (m is OsuModFlashlight fl) + fl.FollowDelay.Value = 1245; + + if (m is OsuModDaycore dc) + dc.SpeedChange.Value = 0.74f; + + if (m is OsuModDifficultyAdjust da) + da.CircleSize.Value = 8.2f; + + if (m is ModAdaptiveSpeed ad) + ad.AdjustPitch.Value = false; + + return m; + })); + }); + } + [Test] public void TestShowRateAdjusts() { AddStep("create mod icons", () => { - Child = new FillFlowContainer + var rateAdjustMods = Ruleset.Value.CreateInstance().CreateAllMods() + .OfType(); + + addRange(rateAdjustMods.SelectMany(m => { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Full, - ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods() - .OfType() - .SelectMany(m => - { - List icons = new List { new ModIcon(m) }; + List mods = new List { m }; - for (double i = m.SpeedChange.MinValue; i < m.SpeedChange.MaxValue; i += m.SpeedChange.Precision * 10) - { - m = (ModRateAdjust)m.DeepClone(); - m.SpeedChange.Value = i; - icons.Add(new ModIcon(m)); - } + for (double i = m.SpeedChange.MinValue; i < m.SpeedChange.MaxValue; i += m.SpeedChange.Precision * 10) + { + m = (ModRateAdjust)m.DeepClone(); + m.SpeedChange.Value = i; + mods.Add(m); + } - return icons; - }), - }; + return mods; + })); }); AddStep("adjust rates", () => @@ -81,21 +153,85 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestChangeModType() { - ModIcon icon = null!; - - AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime())); - AddStep("change mod", () => icon.Mod = new OsuModEasy()); + AddStep("create mod icon", () => addRange([new OsuModDoubleTime()])); + AddStep("change mod", () => + { + foreach (var modIcon in this.ChildrenOfType()) + modIcon.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")); + AddStep("create mod icon", () => addRange([ruleset.AllMods.First(m => m.Acronym == "DT")])); + AddStep("change mod", () => + { + foreach (var modIcon in this.ChildrenOfType()) + modIcon.Mod = ruleset.AllMods.First(m => m.Acronym == "EZ"); + }); + } + + [Test] + public void TestDifficultyAdjust() + { + AddStep("create icons", () => + { + addRange([ + new OsuModDifficultyAdjust + { + CircleSize = { Value = 8 } + }, + new OsuModDifficultyAdjust + { + CircleSize = { Value = 5.5f } + }, + new OsuModDifficultyAdjust + { + CircleSize = { Value = 8 }, + ApproachRate = { Value = 8 }, + OverallDifficulty = { Value = 8 }, + DrainRate = { Value = 8 }, + } + ]); + }); + } + + [Test] + public void TestTooltip() + { + OsuModDoubleTime mod = null!; + + AddStep("create icon", () => addRange([mod = new OsuModDoubleTime()])); + AddStep("hover", () => InputManager.MoveMouseTo(this.ChildrenOfType().First())); + AddUntilStep("tooltip displayed", () => getTooltip()?.IsPresent, () => Is.True); + AddAssert("tooltip text = \"Double Time\"", getTooltipText, () => Is.EqualTo("Double Time")); + AddAssert("tooltip settings empty", () => getTooltipSettingsLabels().Concat(getTooltipSettingsValues()), () => Is.Empty); + + AddStep("change settings", () => mod.SpeedChange.Value = 1.75f); + AddAssert("tooltip text = \"Double Time\"", getTooltipText, () => Is.EqualTo("Double Time")); + AddAssert("tooltip settings updated", + () => getTooltipSettingsLabels().Concat(getTooltipSettingsValues()), + () => Is.EquivalentTo(new[] { "Speed ", "change", "1.75x" })); + + AddStep("change settings", () => mod.SpeedChange.Value = 1.25f); + AddAssert("tooltip text = \"Double Time\"", getTooltipText, () => Is.EqualTo("Double Time")); + AddAssert("tooltip settings updated", + () => getTooltipSettingsLabels().Concat(getTooltipSettingsValues()), + () => Is.EquivalentTo(new[] { "Speed ", "change", "1.25x" })); + + AddStep("rest settings", () => mod.SpeedChange.SetDefault()); + AddAssert("tooltip text = \"Double Time\"", getTooltipText, () => Is.EqualTo("Double Time")); + AddAssert("tooltip settings empty", () => getTooltipSettingsLabels().Concat(getTooltipSettingsValues()), () => Is.Empty); + + ModTooltip? getTooltip() => this.ChildrenOfType().SingleOrDefault(); + + // we could also just expose those directly from ModTooltip, but this works. + string getTooltipText() => getTooltip().ChildrenOfType().First().Text.ToString(); + IEnumerable getTooltipSettingsLabels() => getTooltip().ChildrenOfType().First().ChildrenOfType().Select(t => t.Text.ToString()); + IEnumerable getTooltipSettingsValues() => getTooltip().ChildrenOfType().Last().ChildrenOfType().Select(t => t.Text.ToString()); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs index b7c1428397..c202442f9c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -469,5 +469,13 @@ namespace osu.Game.Tests.Visual.UserInterface Ruleset = rulesets.GetRuleset(3).AsNonNull() } }; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 280497e861..6127be481c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -7,6 +7,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; @@ -993,7 +994,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("column not scrolled", () => modSelectOverlay.ChildrenOfType().Single().IsScrolledToStart()); AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); - AddAssert("customisation panel closed", + AddUntilStep("customisation panel closed", () => this.ChildrenOfType().Single().ExpandedState.Value, () => Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); @@ -1003,6 +1004,35 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("search still not focused", () => !this.ChildrenOfType().Single().HasFocus); } + /// + /// Tests that recreating the mod panels (by setting the global available mods) also refreshes the active states. + /// + [Test] + public void TestActiveStatesRefreshedOnPanelsCreated() + { + createScreen(); + changeRuleset(0); + + Bindable> selectedMods = null!; + + AddStep("bind mods to local bindable", () => + { + selectedMods = new Bindable>([]); + + modSelectOverlay.SelectedMods.UnbindFrom(SelectedMods); + modSelectOverlay.SelectedMods.BindTo(selectedMods); + }); + + AddStep("activate PF", () => selectedMods.Value = [new OsuModPerfect()]); + AddAssert("OsuModPerfect panel active", () => getPanelForMod(typeof(OsuModPerfect)).Active.Value); + + changeRuleset(1); + AddAssert("TaikoModPerfect panel not active", () => !getPanelForMod(typeof(TaikoModPerfect)).Active.Value); + + changeRuleset(0); + AddAssert("OsuModPerfect panel active", () => getPanelForMod(typeof(OsuModPerfect)).Active.Value); + } + private void waitForColumnLoad() => AddUntilStep("all column content loaded", () => modSelectOverlay.ChildrenOfType().Any() && modSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded) @@ -1018,7 +1048,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void assertCustomisationToggleState(bool disabled, bool active) { AddUntilStep($"customisation panel is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.ChildrenOfType().Single().Enabled.Value == !disabled); - AddAssert($"customisation panel is {(active ? "" : "not ")}active", + AddUntilStep($"customisation panel is {(active ? "" : "not ")}active", () => modSelectOverlay.ChildrenOfType().Single().ExpandedState.Value, () => active ? Is.Not.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed) : Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); } @@ -1028,9 +1058,20 @@ namespace osu.Game.Tests.Visual.UserInterface private ModPanel getPanelForMod(Type modType) => modSelectOverlay.ChildrenOfType().Single(panel => panel.Mod.GetType() == modType); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesetStore.IsNotNull()) + rulesetStore.Dispose(); + } + private partial class TestModSelectOverlay : UserModSelectOverlay { - protected override bool ShowPresets => true; + public TestModSelectOverlay() + { + ShowPresets = true; + } } private class TestUnimplementedMod : Mod diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index c584c7dba0..9d23b2130a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using NUnit.Framework; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -64,7 +64,6 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep(@"simple #2", sendAmazingNotification); AddStep(@"progress #1", sendUploadProgress); AddStep(@"progress #2", sendDownloadProgress); - AddStep(@"User notification", sendUserNotification); checkProgressingCount(2); @@ -83,6 +82,40 @@ namespace osu.Game.Tests.Visual.UserInterface waitForCompletion(); } + [Test] + public void TestNormalDoesForwardToOverlay() + { + SimpleNotification notification = null!; + + AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"This shouldn't annoy you too much", + Transient = false, + })); + + AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True); + AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False); + + checkDisplayedCount(1); + } + + [Test] + public void TestTransientDoesNotForwardToOverlay() + { + SimpleNotification notification = null!; + + AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"This shouldn't annoy you too much", + Transient = true, + })); + + AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True); + AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False); + + checkDisplayedCount(0); + } + [Test] public void TestForwardWithFlingRight() { @@ -422,7 +455,7 @@ namespace osu.Game.Tests.Visual.UserInterface { applyUpdate = false; - var updateNotification = new UpdateManager.UpdateProgressNotification + var updateNotification = new UpdateManager.UpdateDownloadProgressNotification(CancellationToken.None) { CompletionClickAction = () => applyUpdate = true }; @@ -434,9 +467,9 @@ namespace osu.Game.Tests.Visual.UserInterface checkProgressingCount(1); waitForCompletion(); - UpdateManager.UpdateApplicationCompleteNotification? completionNotification = null; + UpdateManager.UpdateReadyNotification? completionNotification = null; AddUntilStep("wait for completion notification", - () => (completionNotification = notificationOverlay.ChildrenOfType().SingleOrDefault()) != null); + () => (completionNotification = notificationOverlay.ChildrenOfType().SingleOrDefault()) != null); AddStep("click notification", () => completionNotification?.TriggerClick()); AddUntilStep("wait for update applied", () => applyUpdate); @@ -542,16 +575,6 @@ namespace osu.Game.Tests.Visual.UserInterface progressingNotifications.Add(n); } - private void sendUserNotification() - { - var user = userLookupCache.GetUserAsync(0).GetResultSafely(); - if (user == null) return; - - var n = new UserAvatarNotification(user, $"{user.Username} invited you to a multiplayer match!"); - - notificationOverlay.Post(n); - } - private void sendUploadProgress() { var n = new ProgressNotification @@ -634,12 +657,18 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class BackgroundNotification : SimpleNotification { - public override bool IsImportant => false; + public BackgroundNotification() + { + IsImportant = false; + } } private partial class BackgroundProgressNotification : ProgressNotification { - public override bool IsImportant => false; + public BackgroundProgressNotification() + { + IsImportant = false; + } } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs index 62a493815b..27d2ff97fa 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs @@ -4,20 +4,52 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Screens.Menu; +using osu.Game.Seasonal; +using osuTK; namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneOsuLogo : OsuTestScene { + private OsuLogo? logo; + + private float scale = 1; + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("scale", 0.1, 2, 1, scale => + { + if (logo != null) + Child.Scale = new Vector2(this.scale = (float)scale); + }); + } + [Test] public void TestBasic() { AddStep("Add logo", () => { - Child = new OsuLogo + Child = logo = new OsuLogo { Anchor = Anchor.Centre, Origin = Anchor.Centre, + Scale = new Vector2(scale), + }; + }); + } + + [Test] + public void TestChristmas() + { + AddStep("Add logo", () => + { + Child = logo = new OsuLogoChristmas + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(scale), }; }); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs index bb94912c83..e544fb127d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Overlays.Volume; @@ -59,13 +60,12 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestAltScrollNotBlocked() { - bool scrollReceived = false; + TestGlobalScrollAdjustsVolume volumeAdjust = null!; - AddStep("add volume control receptor", () => Add(new VolumeControlReceptor + AddStep("add volume control receptor", () => Add(volumeAdjust = new TestGlobalScrollAdjustsVolume { RelativeSizeAxes = Axes.Both, Depth = float.MaxValue, - ScrollActionRequested = (_, _, _) => scrollReceived = true, })); AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft)); @@ -75,10 +75,21 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.ScrollVerticalBy(10); }); - AddAssert("receptor received scroll input", () => scrollReceived); + AddAssert("receptor received scroll input", () => volumeAdjust.ScrollReceived); AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); } + public partial class TestGlobalScrollAdjustsVolume : GlobalScrollAdjustsVolume + { + public bool ScrollReceived { get; private set; } + + protected override bool OnScroll(ScrollEvent e) + { + ScrollReceived = true; + return base.OnScroll(e); + } + } + private partial class TestOverlay : OsuFocusedOverlayContainer { [BackgroundDependencyLoader] diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs index c723988d6a..b6d9bad5bb 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs @@ -5,18 +5,16 @@ 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.Platform; -using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Collections; using osu.Game.Database; using osu.Game.Overlays.Music; using osu.Game.Rulesets; using osu.Game.Tests.Resources; using osuTK; -using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { @@ -24,8 +22,7 @@ namespace osu.Game.Tests.Visual.UserInterface { protected override bool UseFreshStoragePerRun => true; - private PlaylistOverlay playlistOverlay = null!; - + private RulesetStore rulesets = null!; private BeatmapManager beatmapManager = null!; private const int item_count = 20; @@ -35,7 +32,7 @@ namespace osu.Game.Tests.Visual.UserInterface [BackgroundDependencyLoader] private void load(GameHost host) { - Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -48,7 +45,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(300, 500), - Child = playlistOverlay = new PlaylistOverlay + Child = new PlaylistOverlay { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -68,115 +65,12 @@ namespace osu.Game.Tests.Visual.UserInterface Realm.Run(r => r.Refresh()); }); - [Test] - public void TestRearrangeItems() + protected override void Dispose(bool isDisposing) { - AddUntilStep("wait for load complete", () => - { - return this - .ChildrenOfType() - .Count(i => i.ChildrenOfType().First().DelayedLoadCompleted) > 6; - }); + base.Dispose(isDisposing); - AddUntilStep("wait for animations to complete", () => !playlistOverlay.Transforms.Any()); - - PlaylistItem firstItem = null!; - - AddStep("hold 1st item handle", () => - { - firstItem = this.ChildrenOfType().First(); - var handle = firstItem.ChildrenOfType().First(); - - InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre); - InputManager.PressButton(MouseButton.Left); - }); - - AddStep("drag to 5th", () => - { - var item = this.ChildrenOfType().ElementAt(4); - InputManager.MoveMouseTo(item.ScreenSpaceDrawQuad.BottomLeft); - }); - - AddAssert("first is moved", () => playlistOverlay.ChildrenOfType().Single().Items.ElementAt(4).Value.Equals(firstItem.Model.Value)); - - AddStep("release handle", () => InputManager.ReleaseButton(MouseButton.Left)); - } - - [Test] - public void TestFiltering() - { - AddStep("set filter to \"10\"", () => - { - var filterControl = playlistOverlay.ChildrenOfType().Single(); - filterControl.Search.Current.Value = "10"; - }); - - AddAssert("results filtered correctly", - () => playlistOverlay.ChildrenOfType() - .Where(item => item.MatchingFilter) - .All(item => item.FilterTerms.Any(term => term.ToString().Contains("10")))); - - AddStep("Import new non-matching beatmap", () => - { - var testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(1); - testBeatmapSetInfo.Beatmaps.Single().Metadata.Title = "no guid"; - beatmapManager.Import(testBeatmapSetInfo); - }); - - AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); - - AddAssert("results filtered correctly", - () => playlistOverlay.ChildrenOfType() - .Where(item => item.MatchingFilter) - .All(item => item.FilterTerms.Any(term => term.ToString().Contains("10")))); - } - - [Test] - public void TestCollectionFiltering() - { - NowPlayingCollectionDropdown collectionDropdown() => playlistOverlay.ChildrenOfType().Single(); - - AddStep("Add collection", () => - { - Realm.Write(r => - { - r.RemoveAll(); - r.Add(new BeatmapCollection("wang")); - }); - }); - - AddUntilStep("wait for dropdown to have new collection", () => collectionDropdown().Items.Count() == 2); - - AddStep("Filter to collection", () => - { - collectionDropdown().Current.Value = collectionDropdown().Items.Last(); - }); - - AddUntilStep("No items present", () => !playlistOverlay.ChildrenOfType().Any(i => i.MatchingFilter)); - - AddStep("Import new non-matching beatmap", () => - { - beatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(1)); - }); - - AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); - - AddUntilStep("No items matching", () => !playlistOverlay.ChildrenOfType().Any(i => i.MatchingFilter)); - - BeatmapSetInfo collectionAddedBeatmapSet = null!; - - AddStep("Import new matching beatmap", () => - { - collectionAddedBeatmapSet = TestResources.CreateTestBeatmapSetInfo(1); - - beatmapManager.Import(collectionAddedBeatmapSet); - Realm.Write(r => r.All().First().BeatmapMD5Hashes.Add(collectionAddedBeatmapSet.Beatmaps.First().MD5Hash)); - }); - - AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); - - AddUntilStep("Only matching item", - () => playlistOverlay.ChildrenOfType().Where(i => i.MatchingFilter).Select(i => i.Model.ID), () => Is.EquivalentTo(new[] { collectionAddedBeatmapSet.ID })); + if (rulesets.IsNotNull()) + rulesets.Dispose(); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs index 3a1eb554ab..7ec57c9e5e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs @@ -138,7 +138,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("section top is visible", () => { var scrollContainer = container.ChildrenOfType().Single(); - float sectionPosition = scrollContainer.GetChildPosInContent(container.Children[scrollIndex]); + double sectionPosition = scrollContainer.GetChildPosInContent(container.Children[scrollIndex]); return scrollContainer.Current < sectionPosition; }); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearAligningWrapper.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearAligningWrapper.cs new file mode 100644 index 0000000000..eb65de8fdc --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearAligningWrapper.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneShearAligningWrapper : OsuTestScene + { + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + private ShearedBox first = null!; + private ShearedBox second = null!; + private ShearedBox third = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 200f, + AutoSizeAxes = Axes.Y, + Shear = OsuGame.SHEAR, + CornerRadius = 10f, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + new ShearAligningWrapper(first = new ShearedBox("Text 1", OsuColour.Gray(0.4f)) + { + RelativeSizeAxes = Axes.X, + Height = 30, + }), + new ShearAligningWrapper(second = new ShearedBox("Text 2", OsuColour.Gray(0.3f)) + { + RelativeSizeAxes = Axes.X, + Height = 30, + }), + new ShearAligningWrapper(third = new ShearedBox("Text 3", OsuColour.Gray(0.2f)) + { + RelativeSizeAxes = Axes.X, + Height = 30, + }), + } + } + }, + }; + }); + + [SetUpSteps] + public void SetUpSteps() + { + AddSliderStep("box 1 height", 0, 100, 30, h => + { + if (first.IsNotNull()) + first.Height = h; + }); + AddSliderStep("box 2 height", 0, 100, 30, h => + { + if (second.IsNotNull()) + second.Height = h; + }); + AddSliderStep("box 3 height", 0, 100, 30, h => + { + if (third.IsNotNull()) + third.Height = h; + }); + } + + public partial class ShearedBox : Container + { + private readonly string text; + private readonly Color4 boxColour; + + public ShearedBox(string text, Color4 boxColour) + { + this.text = text; + this.boxColour = boxColour; + } + + [BackgroundDependencyLoader] + private void load() + { + CornerRadius = 10; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = boxColour, + }, + new OsuSpriteText + { + Text = text, + Colour = Color4.White, + Shear = -OsuGame.SHEAR, + Font = OsuFont.Torus.With(size: 24), + Margin = new MarginPadding { Left = 50 }, + } + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs index 8db22f2d65..bdec96f446 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs @@ -13,7 +13,6 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; -using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface @@ -183,32 +182,31 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - Scale = new Vector2(2.5f), Children = new Drawable[] { - new ShearedButton(120) + new ShearedButton { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "Test", + Text = "Button", Action = () => { }, - Padding = new MarginPadding(), + Height = 30, }, - new ShearedButton(120, 40) + new ShearedButton { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "Test", + Text = "Button", Action = () => { }, - Padding = new MarginPadding { Left = -1f }, + Height = 30, }, - new ShearedButton(120, 70) + new ShearedButton { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "Test", + Text = "Button", Action = () => { }, - Padding = new MarginPadding { Left = 3f }, + Height = 30, }, } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedDropdown.cs new file mode 100644 index 0000000000..d650ce6c36 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedDropdown.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneShearedDropdown : ThemeComparisonTestScene + { + public TestSceneShearedDropdown() + : base(false) + { + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black.Opacity(0.75f), + RelativeSizeAxes = Axes.Both, + }, + new ShearedDropdown("Test") + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = 300f, + Width = 140, + Current = new Bindable(), + Items = new[] { "Global", "Friends", "Local", "Really lonnnnnnng option" }, + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedRangeSlider.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedRangeSlider.cs new file mode 100644 index 0000000000..fdc5b5948a --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedRangeSlider.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.Linq; +using NUnit.Framework; +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.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneShearedRangeSlider : ThemeComparisonTestScene + { + private readonly BindableNumber customStart = new BindableNumber + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f + }; + + private readonly BindableNumber customEnd = new BindableNumber(10) + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f + }; + + private ShearedRangeSlider shearedRangeSlider = null!; + + public TestSceneShearedRangeSlider() + : base(false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + CreateThemedContent(OverlayColourScheme.Aquamarine); + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.5f), + }, + shearedRangeSlider = new ShearedRangeSlider("Test") + { + Width = 600, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1), + LowerBound = customStart, + UpperBound = customEnd, + NubWidth = 32, + DefaultStringLowerBound = "0.0", + DefaultStringUpperBound = "∞", + MinRange = 0.1f, + } + } + }; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset range", () => + { + customStart.SetDefault(); + customEnd.SetDefault(); + }); + + AddAssert("Initial lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(0).Within(0.1f)); + AddAssert("Initial upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(10).Within(0.1f)); + } + + [Test] + public void TestAdjustRange() + { + AddStep("Adjust range", () => + { + customStart.Value = 5; + customEnd.Value = 7.5; + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(5).Within(0.1f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.1f)); + + AddStep("Test nub pushing", () => + { + customStart.Value = 9; + }); + + AddAssert("Pushed lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(9).Within(0.1f)); + AddAssert("Pushed upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(9.1).Within(0.1f)); + } + + [Test] + public void TestAdjustRangeClickOutsideNub() + { + Vector2 lowerBoundNub = Vector2.Zero; + Vector2 upperBoundNub = Vector2.Zero; + + AddStep("click 75%", () => + { + // save out original positions so we can use as absolute selection range. + lowerBoundNub = shearedRangeSlider.ChildrenOfType().Last().ScreenSpaceDrawQuad.Centre - OsuGame.SHEAR * 2; + upperBoundNub = shearedRangeSlider.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre - OsuGame.SHEAR * 2; + + InputManager.MoveMouseTo(lowerBoundNub + new Vector2((upperBoundNub.X - lowerBoundNub.X) * 0.75f, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(0).Within(0.11f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.11f)); + + AddStep("click 30%", () => + { + InputManager.MoveMouseTo(lowerBoundNub + new Vector2((upperBoundNub.X - lowerBoundNub.X) * 0.3f, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(3.0).Within(0.11f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.11f)); + + AddStep("click 0%", () => + { + InputManager.MoveMouseTo(lowerBoundNub); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(0).Within(0.11f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.11f)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs index f3a7f1481a..0ecaf4900a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs @@ -5,8 +5,10 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Tests.Visual.UserInterface { @@ -30,16 +32,32 @@ namespace osu.Game.Tests.Visual.UserInterface { (typeof(OverlayColourProvider), colourProvider) }, - Children = new Drawable[] + Child = new FillFlowContainer { - new ShearedSearchTextBox + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Width = 0.5f + new ShearedSearchTextBox + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.5f + }, + new ShearedFilterTextBox + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + StatusText = "12345 matches", + }, } - } + }, }; } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs index c3038ddb3d..7a654fcb4b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs @@ -3,38 +3,50 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneShearedSliderBar : OsuManualInputManagerTestScene + public partial class TestSceneShearedSliderBar : ThemeComparisonTestScene { - [Cached] - private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Purple); + private TestSliderBar slider = null!; - private ShearedSliderBar slider = null!; - - [SetUpSteps] - public void SetUpSteps() + protected override Drawable CreateContent() => slider = new TestSliderBar { - AddStep("create slider", () => Child = slider = new ShearedSliderBar + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = new BindableDouble(5) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Current = new BindableDouble(5) + Precision = 0.1, + MinValue = 0, + MaxValue = 15 + }, + RelativeSizeAxes = Axes.X, + Width = 0.4f + }; + + [Test] + public void TestNubDisplay() + { + AddSliderStep("nub width", 20, 80, 50, v => + { + if (slider.IsNotNull()) { - Precision = 0.1, - MinValue = 0, - MaxValue = 15 - }, - RelativeSizeAxes = Axes.X, - Width = 0.4f + slider.Nub.Width = v; + slider.RangePadding = v / 2f; + } + }); + AddToggleStep("nub shadow", v => + { + if (slider.IsNotNull()) + slider.NubShadowColour = v ? Color4.Black.Opacity(0.2f) : Color4.Black.Opacity(0f); }); } @@ -69,6 +81,12 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddAssert("slider is still at 1", () => slider.Current.Value, () => Is.EqualTo(1)); + AddStep("enable slider", () => slider.Current.Disabled = false); + } + + public partial class TestSliderBar : ShearedSliderBar + { + public new ShearedNub Nub => base.Nub; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs index 94117ff7e3..49eb1f092c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs @@ -30,8 +30,8 @@ namespace osu.Game.Tests.Visual.UserInterface Position = new Vector2(275, 5) }); - filter.PinItem(GroupMode.All); - filter.PinItem(GroupMode.RecentlyPlayed); + filter.PinItem(GroupMode.None); + filter.PinItem(GroupMode.LastPlayed); filter.Current.ValueChanged += grouping => { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs index 52543c68ce..c2b8ec76f4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.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. -#nullable disable - +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Overlays; using osu.Game.Overlays.Volume; @@ -11,7 +10,14 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneVolumeOverlay : OsuTestScene { - private VolumeOverlay volume; + private VolumeOverlay volume = null!; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(volume = new VolumeOverlay()); + return dependencies; + } protected override void LoadComplete() { @@ -19,12 +25,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddRange(new Drawable[] { - volume = new VolumeOverlay(), - new VolumeControlReceptor + volume, + new GlobalScrollAdjustsVolume { RelativeSizeAxes = Axes.Both, - ActionRequested = action => volume.Adjust(action), - ScrollActionRequested = (action, amount, isPrecise) => volume.Adjust(action, amount, isPrecise), }, }); diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index 12660ed2e1..2da54eb055 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -39,12 +39,6 @@ namespace osu.Game.Tests trackStore = audioManager.GetTrackStore(getZipReader()); } - ~WaveformTestBeatmap() - { - // Remove the track store from the audio manager - trackStore?.Dispose(); - } - private static Stream getStream() => TestResources.GetTestBeatmapStream(); private static ZipArchiveReader getZipReader() => new ZipArchiveReader(getStream()); diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 01d2241650..c86f05c257 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,11 +1,12 @@  - + + - + diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs index de91a66e56..4b1d56dea2 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs @@ -6,6 +6,8 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; @@ -61,14 +63,33 @@ namespace osu.Game.Tournament.Tests.Components Anchor = Anchor.Centre, Origin = Anchor.Centre, }); - - chatDisplay.Channel.Value = testChannel; } protected override void LoadComplete() { base.LoadComplete(); + AddStep("set up API", () => + { + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case JoinChannelRequest joinChannelRequest: + joinChannelRequest.TriggerSuccess(); + return true; + + case LeaveChannelRequest leaveChannelRequest: + leaveChannelRequest.TriggerSuccess(); + return true; + + default: + return false; + } + }; + }); + AddStep("set channel", () => chatDisplay.Channel.Value = testChannel); + AddStep("message from admin", () => testChannel.AddNewMessages(new Message(nextMessageId()) { Sender = admin, @@ -152,6 +173,12 @@ namespace osu.Game.Tournament.Tests.Components AddStep("change channel to 2", () => chatDisplay.Channel.Value = testChannel2); AddStep("change channel to 1", () => chatDisplay.Channel.Value = testChannel); + + AddStep("!mp message (shouldn't display)", () => testChannel.AddNewMessages(new Message(nextMessageId()) + { + Sender = redUser.ToAPIUser(), + Content = "!mp wangs" + })); } private int messageId; diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs index 31583bf8b7..eb9faa5930 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Screens.Gameplay; @@ -66,6 +67,6 @@ namespace osu.Game.Tournament.Tests.Screens () => this.ChildrenOfType().All(score => score.Alpha == (visible ? 1 : 0))); private void toggleWarmup() - => AddStep("toggle warmup", () => this.ChildrenOfType().First().TriggerClick()); + => AddStep("toggle warmup", () => this.ChildrenOfType().First().ChildrenOfType().First().TriggerClick()); } } diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 04683cd83b..8437a1bc4e 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,9 +4,9 @@ osu.Game.Tournament.Tests.TournamentTestRunner - + - + WinExe diff --git a/osu.Game.Tournament/Components/DrawableTeamFlag.cs b/osu.Game.Tournament/Components/DrawableTeamFlag.cs index aef854bb8d..90638a7758 100644 --- a/osu.Game.Tournament/Components/DrawableTeamFlag.cs +++ b/osu.Game.Tournament/Components/DrawableTeamFlag.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Tournament.Models; @@ -35,12 +36,20 @@ namespace osu.Game.Tournament.Components Size = new Vector2(75, 54); Masking = true; CornerRadius = 5; - Child = flagSprite = new Sprite + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.FromHex("333"), + }, + flagSprite = new Sprite + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fit + }, }; (flag = team.FlagName.GetBoundCopy()).BindValueChanged(_ => flagSprite.Texture = textures.Get($@"Flags/{team.FlagName}"), true); diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index ae59e92e33..cff86cf0a1 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics; using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Screens.Menu; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -207,7 +208,7 @@ namespace osu.Game.Tournament.Components Children = new Drawable[] { new DiffPiece(stats), - new DiffPiece(("Star Rating", $"{beatmap.StarRating:0.00}{srExtra}")) + new DiffPiece(("Star Rating", $"{beatmap.StarRating.FormatStarRating()}{srExtra}")) } }, new FillFlowContainer diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index 0998e606e9..02fb5a7ae0 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +16,7 @@ namespace osu.Game.Tournament.Components { public partial class TournamentMatchChatDisplay : StandAloneChatDisplay { - private readonly Bindable chatChannel = new Bindable(); + private readonly Bindable channelName = new Bindable(); private ChannelManager? manager; @@ -33,46 +34,46 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader] - private void load(MatchIPCInfo? ipc, IAPIProvider api) + private void load(MatchIPCInfo ipc, IAPIProvider api) { - if (ipc != null) + AddInternal(manager = new ChannelManager(api)); + Channel.BindTo(manager.CurrentChannel); + + channelName.BindTo(ipc.ChatChannel); + channelName.BindValueChanged(c => { - chatChannel.BindTo(ipc.ChatChannel); - chatChannel.BindValueChanged(c => + if (int.TryParse(c.OldValue, out int oldChannelId) && oldChannelId > 0) { - if (string.IsNullOrWhiteSpace(c.NewValue)) - return; - - int id = int.Parse(c.NewValue); - - if (id <= 0) return; - - if (manager == null) - { - AddInternal(manager = new ChannelManager(api)); - Channel.BindTo(manager.CurrentChannel); - } - - foreach (var ch in manager.JoinedChannels.ToList()) - manager.LeaveChannel(ch); + var joinedChannel = manager.JoinedChannels.SingleOrDefault(ch => ch.Id == oldChannelId); + if (joinedChannel != null) + manager.LeaveChannel(joinedChannel); + } + if (int.TryParse(c.NewValue, out int newChannelId) && newChannelId > 0) + { var channel = new Channel { - Id = id, + Id = newChannelId, Type = ChannelType.Public }; manager.JoinChannel(channel); manager.CurrentChannel.Value = channel; - }, true); - } + } + }, true); } public void Expand() => this.FadeIn(300); public void Contract() => this.FadeOut(200); - protected override ChatLine CreateMessage(Message message) => new MatchMessage(message, ladderInfo); + protected override ChatLine? CreateMessage(Message message) + { + if (message.Content.StartsWith("!mp", StringComparison.Ordinal)) + return null; + + return new MatchMessage(message, ladderInfo); + } protected override StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => new MatchChannel(channel); diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index 0a700eb4d6..1d91febd1a 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -19,6 +19,7 @@ namespace osu.Game.Tournament.Models { public int ID; + [JsonIgnore] public List Acronyms { get diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 250d5acaae..162379f4aa 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -71,6 +71,8 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] private LadderInfo ladderInfo { get; set; } = null!; + private readonly SettingsTextBox acronymTextBox; + public TeamRow(TournamentTeam team, TournamentScreen parent) { Model = team; @@ -112,7 +114,7 @@ namespace osu.Game.Tournament.Screens.Editors Width = 0.2f, Current = Model.FullName }, - new SettingsTextBox + acronymTextBox = new SettingsTextBox { LabelText = "Acronym", Width = 0.2f, @@ -177,6 +179,27 @@ namespace osu.Game.Tournament.Screens.Editors }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + Model.Acronym.BindValueChanged(acronym => + { + var teamsWithSameAcronym = ladderInfo.Teams + .Where(t => t.Acronym.Value == acronym.NewValue && t != Model) + .ToList(); + + if (teamsWithSameAcronym.Count > 0) + { + acronymTextBox.SetNoticeText( + $"Acronym '{acronym.NewValue}' is already in use by team{(teamsWithSameAcronym.Count > 1 ? "s" : "")}:\n" + + $"{string.Join(",\n", teamsWithSameAcronym)}", true); + } + else + acronymTextBox.ClearNoticeText(); + }, true); + } + private partial class LastYearPlacementSlider : RoundedSliderBar { public override LocalisableString TooltipText => Current.Value == 0 ? "N/A" : base.TooltipText; diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index b2152eaf3d..2cf7ce1961 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -7,7 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Threading; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays.Settings; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; @@ -24,7 +24,6 @@ namespace osu.Game.Tournament.Screens.Gameplay private readonly BindableBool warmup = new BindableBool(); public readonly Bindable State = new Bindable(); - private OsuButton warmupButton = null!; private MatchIPCInfo ipc = null!; [Resolved] @@ -40,6 +39,8 @@ namespace osu.Game.Tournament.Screens.Gameplay { this.ipc = ipc; + LabelledSwitchButton chatToggle; + AddRangeInternal(new Drawable[] { new TourneyVideo("gameplay") @@ -95,17 +96,14 @@ namespace osu.Game.Tournament.Screens.Gameplay { Children = new Drawable[] { - warmupButton = new TourneyButton + new LabelledSwitchButton { - RelativeSizeAxes = Axes.X, - Text = "Toggle warmup", - Action = () => warmup.Toggle() + Label = "Warmup", + Current = warmup, }, - new TourneyButton + chatToggle = new LabelledSwitchButton { - RelativeSizeAxes = Axes.X, - Text = "Toggle chat", - Action = () => { State.Value = State.Value == TourneyState.Idle ? TourneyState.Playing : TourneyState.Idle; } + Label = "Show chat", }, new SettingsSlider { @@ -123,13 +121,12 @@ namespace osu.Game.Tournament.Screens.Gameplay } }); + State.BindValueChanged(state => chatToggle.Current.Value = State.Value == TourneyState.Idle, true); + chatToggle.Current.BindValueChanged(v => State.Value = v.NewValue ? TourneyState.Idle : TourneyState.Playing); + LadderInfo.ChromaKeyWidth.BindValueChanged(width => chroma.Width = width.NewValue, true); - warmup.BindValueChanged(w => - { - warmupButton.Alpha = !w.NewValue ? 0.5f : 1; - header.ShowScores = !w.NewValue; - }, true); + warmup.BindValueChanged(w => header.ShowScores = !w.NewValue, true); } protected override void LoadComplete() diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index d02559d6b7..848d510826 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -9,6 +9,7 @@ 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.Graphics; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -264,9 +265,9 @@ namespace osu.Game.Tournament.Screens.Schedule { } - protected override string Format() => Date < DateTimeOffset.Now - ? $"Started {base.Format()}" - : $"Starting {base.Format()}"; + protected override LocalisableString Format() => Date < DateTimeOffset.Now + ? LocalisableString.Interpolate($"Started {base.Format()}") + : LocalisableString.Interpolate($"Starting {base.Format()}"); } public partial class ScheduleContainer : Container diff --git a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs index fed9d625ee..536e8ba767 100644 --- a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs @@ -12,6 +12,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Tournament.IPC; @@ -42,6 +43,7 @@ namespace osu.Game.Tournament.Screens.Setup [Resolved] private TournamentSceneManager? sceneManager { get; set; } + private readonly IBindable localUser = new Bindable(); private Bindable windowSize = null!; [BackgroundDependencyLoader] @@ -70,7 +72,8 @@ namespace osu.Game.Tournament.Screens.Setup }, }; - api.LocalUser.BindValueChanged(_ => Schedule(reload)); + localUser.BindTo(api.LocalUser); + localUser.BindValueChanged(_ => Schedule(reload)); stableInfo.OnStableInfoSaved += () => Schedule(reload); reload(); } diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index eecd097a97..2be7c4aff3 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -53,6 +53,14 @@ namespace osu.Game.Tournament return new ProductionEndpointConfiguration(); } + public override void SetHost(GameHost host) + { + base.SetHost(host); + + if (host.Window != null) + host.Window.Title = $"{Name} [tournament client]"; + } + private TournamentSpriteText initialisationText = null!; [BackgroundDependencyLoader] diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index 19273e3714..ff6f3b43bb 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -33,12 +33,12 @@ namespace osu.Game.Audio /// /// All valid sample addition constants. /// - public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_FINISH, HIT_CLAP }; + public static readonly string[] ALL_ADDITIONS = [HIT_WHISTLE, HIT_FINISH, HIT_CLAP]; /// /// All valid bank constants. /// - public static IEnumerable AllBanks => new[] { BANK_NORMAL, BANK_SOFT, BANK_DRUM }; + public static readonly string[] ALL_BANKS = [BANK_NORMAL, BANK_SOFT, BANK_DRUM]; /// /// The name of the sample to load. @@ -65,13 +65,19 @@ namespace osu.Game.Audio /// public bool EditorAutoBank { get; } - public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100, bool editorAutoBank = true) + /// + /// Whether the sample can be looked up from the beatmap's skin. + /// + public bool UseBeatmapSamples { get; } + + public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100, bool editorAutoBank = true, bool useBeatmapSamples = false) { Name = name; Bank = bank; Suffix = suffix; Volume = volume; EditorAutoBank = editorAutoBank; + UseBeatmapSamples = useBeatmapSamples; } /// @@ -99,16 +105,19 @@ namespace osu.Game.Audio /// An optional new lookup suffix. /// An optional new volume. /// An optional new editor auto bank flag. + /// An optional use beatmap samples flag. /// The new . - public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, Optional newEditorAutoBank = default) - => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank)); + public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, + Optional newEditorAutoBank = default, Optional newUseBeatmapSamples = default) + => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume), + newEditorAutoBank.GetOr(EditorAutoBank), newUseBeatmapSamples.GetOr(UseBeatmapSamples)); public virtual bool Equals(HitSampleInfo? other) - => other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix; + => other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix && UseBeatmapSamples == other.UseBeatmapSamples; public override bool Equals(object? obj) => obj is HitSampleInfo other && Equals(other); - public override int GetHashCode() => HashCode.Combine(Name, Bank, Suffix); + public override int GetHashCode() => HashCode.Combine(Name, Bank, Suffix, UseBeatmapSamples); } } diff --git a/osu.Game/Audio/IPreviewTrackOwner.cs b/osu.Game/Audio/IPreviewTrackOwner.cs index 8ab93257a5..e9653aad22 100644 --- a/osu.Game/Audio/IPreviewTrackOwner.cs +++ b/osu.Game/Audio/IPreviewTrackOwner.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.Allocation; + namespace osu.Game.Audio { /// @@ -10,6 +12,7 @@ namespace osu.Game.Audio /// s can cancel the currently playing through the /// global if they're the owner of the playing . /// + [Cached] public interface IPreviewTrackOwner { } diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 1d710e6395..d3ab86a8a0 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -6,9 +6,9 @@ using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Online; namespace osu.Game.Audio { @@ -30,7 +30,7 @@ namespace osu.Game.Audio [BackgroundDependencyLoader] private void load(AudioManager audioManager) { - trackStore = audioManager.GetTrackStore(new OnlineStore()); + trackStore = audioManager.GetTrackStore(new TrustedDomainOnlineStore()); } /// diff --git a/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs b/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs index 34eedfb474..e15de7ec02 100644 --- a/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs @@ -63,7 +63,9 @@ namespace osu.Game.Beatmaps DateRanked = res.BeatmapSet?.Ranked, DateSubmitted = res.BeatmapSet?.Submitted, MD5Hash = res.MD5Hash, - LastUpdated = res.LastUpdated + LastUpdated = res.LastUpdated, + // Tags are not populated because the response does not contain tag data. + // TODO: consider web change to include the tag data? or a second web request for the set to retrieve tags? }; return true; } diff --git a/osu.Game/Beatmaps/APIBeatmapTag.cs b/osu.Game/Beatmaps/APIBeatmapTag.cs new file mode 100644 index 0000000000..5f4f9b851d --- /dev/null +++ b/osu.Game/Beatmaps/APIBeatmapTag.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 Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public class APIBeatmapTag + { + [JsonProperty("tag_id")] + public long TagId { get; set; } + + [JsonProperty("count")] + public int VoteCount { get; set; } + } +} diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 8ea6fa1f51..155ded5747 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -9,6 +9,7 @@ using System.Linq; using osu.Game.Beatmaps.ControlPoints; using Newtonsoft.Json; using osu.Framework.Lists; +using osu.Game.Beatmaps.Formats; using osu.Game.IO.Serialization.Converters; namespace osu.Game.Beatmaps @@ -141,6 +142,8 @@ namespace osu.Game.Beatmaps public int[] Bookmarks { get; set; } = Array.Empty(); + public int BeatmapVersion { get; set; } = LegacyBeatmapEncoder.FIRST_LAZER_VERSION; + IBeatmap IBeatmap.Clone() => Clone(); public Beatmap Clone() => (Beatmap)MemberwiseClone(); diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index 0cf10c659b..f0cb6d0484 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -86,6 +86,7 @@ namespace osu.Game.Beatmaps beatmap.Countdown = original.Countdown; beatmap.CountdownOffset = original.CountdownOffset; beatmap.Bookmarks = original.Bookmarks; + beatmap.BeatmapVersion = original.BeatmapVersion; return beatmap; } diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index fc4175415c..4ef484cb67 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -98,12 +98,17 @@ namespace osu.Game.Beatmaps /// /// The to get the difficulty of. /// An optional which stops updating the star difficulty for the given . - /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state (but not during updates to ruleset and mods if a stale value is already propagated). - public IBindable GetBindableDifficulty(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) + /// A delay in milliseconds before performing the + /// A bindable that is updated to contain the star difficulty when it becomes available. May be an approximation while in an initial calculating state. + public IBindable GetBindableDifficulty(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken = default, int computationDelay = 0) { - var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); + var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken) + { + // Start with an approximate known value instead of zero. + Value = new StarDifficulty(beatmapInfo.StarRating, 0) + }; - updateBindable(bindable, currentRuleset.Value, currentMods.Value, cancellationToken); + updateBindable(bindable, currentRuleset.Value, currentMods.Value, cancellationToken, computationDelay); lock (bindableUpdateLock) trackedBindables.Add(bindable); @@ -118,13 +123,14 @@ namespace osu.Game.Beatmaps /// The to get the difficulty with. /// The s to get the difficulty with. /// An optional which stops computing the star difficulty. + /// In the case a cached lookup was not possible, a value in milliseconds of to wait until performing potentially intensive lookup. /// /// The requested , if non-. /// A return value indicates that the difficulty process failed or was interrupted early, /// and as such there is no usable star difficulty value to be returned. /// - public virtual Task GetDifficultyAsync(IBeatmapInfo beatmapInfo, IRulesetInfo? rulesetInfo = null, - IEnumerable? mods = null, CancellationToken cancellationToken = default) + public virtual Task GetDifficultyAsync(IBeatmapInfo beatmapInfo, IRulesetInfo? rulesetInfo = null, IEnumerable? mods = null, + CancellationToken cancellationToken = default, int computationDelay = 0) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. rulesetInfo ??= beatmapInfo.Ruleset; @@ -139,7 +145,7 @@ namespace osu.Game.Beatmaps return Task.FromResult(new StarDifficulty(beatmapInfo.StarRating, (beatmapInfo as IBeatmapOnlineInfo)?.MaxCombo ?? 0)); } - return GetAsync(new DifficultyCacheLookup(localBeatmapInfo, localRulesetInfo, mods), cancellationToken); + return GetAsync(new DifficultyCacheLookup(localBeatmapInfo, localRulesetInfo, mods), cancellationToken, computationDelay); } protected override Task ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken cancellationToken = default) @@ -206,11 +212,12 @@ namespace osu.Game.Beatmaps /// The to update with. /// The s to update with. /// A token that may be used to cancel this update. - private void updateBindable(BindableStarDifficulty bindable, IRulesetInfo? rulesetInfo, IEnumerable? mods, CancellationToken cancellationToken = default) + /// In the case a cached lookup was not possible, a value in milliseconds of to wait until performing potentially intensive lookup. + private void updateBindable(BindableStarDifficulty bindable, IRulesetInfo? rulesetInfo, IEnumerable? mods, CancellationToken cancellationToken = default, int computationDelay = 0) { // GetDifficultyAsync will fall back to existing data from IBeatmapInfo if not locally available // (contrary to GetAsync) - GetDifficultyAsync(bindable.BeatmapInfo, rulesetInfo, mods, cancellationToken) + GetDifficultyAsync(bindable.BeatmapInfo, rulesetInfo, mods, cancellationToken, computationDelay) .ContinueWith(task => { // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. @@ -339,7 +346,7 @@ namespace osu.Game.Beatmaps } } - private class BindableStarDifficulty : Bindable + private class BindableStarDifficulty : Bindable { public readonly IBeatmapInfo BeatmapInfo; public readonly CancellationToken CancellationToken; diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 77aca5eecf..f80c4de4ea 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -72,58 +72,66 @@ namespace osu.Game.Beatmaps first.PerformWrite(updated => { - var realm = updated.Realm; - - Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); - - // Re-fetch as we are likely on a different thread. - original = realm!.Find(originalId)!; - - // Generally the import process will do this for us if the OnlineIDs match, - // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). - original.DeletePending = true; - - // Transfer local values which should be persisted across a beatmap update. - updated.DateAdded = originalDateAdded; - - transferCollectionReferences(realm, original, updated); - - foreach (var beatmap in original.Beatmaps.ToArray()) + try { - var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.Hash); + var realm = updated.Realm; - if (updatedBeatmap != null) + // Re-fetch as we are likely on a different thread. + original = realm!.Find(originalId)!; + + // Generally the import process will do this for us if the OnlineIDs match, + // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). + original.DeletePending = true; + + // Transfer local values which should be persisted across a beatmap update. + updated.DateAdded = originalDateAdded; + + transferCollectionReferences(realm, original, updated); + + foreach (var beatmap in original.Beatmaps.ToArray()) { - // If the updated beatmap matches an existing one, transfer any user data across.. - if (beatmap.Scores.Any()) + var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.Hash); + + if (updatedBeatmap != null) { - Logger.Log($"Transferring {beatmap.Scores.Count()} scores for unchanged difficulty \"{beatmap}\"", LoggingTarget.Database); + // If the updated beatmap matches an existing one, transfer any user data across.. + if (beatmap.Scores.Any()) + { + Logger.Log($"Transferring {beatmap.Scores.Count()} scores for unchanged difficulty \"{beatmap}\"", LoggingTarget.Database); - foreach (var score in beatmap.Scores) - score.BeatmapInfo = updatedBeatmap; + foreach (var score in beatmap.Scores) + score.BeatmapInfo = updatedBeatmap; + } + + // ..then nuke the old beatmap completely. + // this is done instead of a soft deletion to avoid a user potentially creating weird + // interactions, like restoring the outdated beatmap then updating a second time + // (causing user data to be wiped). + original.Beatmaps.Remove(beatmap); + + realm.Remove(beatmap.Metadata); + realm.Remove(beatmap); + } + else + { + // If the beatmap differs in the original, leave it in a soft-deleted state but reset online info. + // This caters to the case where a user has made modifications they potentially want to restore, + // but after restoring we want to ensure it can't be used to trigger an update of the beatmap. + beatmap.ResetOnlineInfo(); } - - // ..then nuke the old beatmap completely. - // this is done instead of a soft deletion to avoid a user potentially creating weird - // interactions, like restoring the outdated beatmap then updating a second time - // (causing user data to be wiped). - original.Beatmaps.Remove(beatmap); - - realm.Remove(beatmap.Metadata); - realm.Remove(beatmap); - } - else - { - // If the beatmap differs in the original, leave it in a soft-deleted state but reset online info. - // This caters to the case where a user has made modifications they potentially want to restore, - // but after restoring we want to ensure it can't be used to trigger an update of the beatmap. - beatmap.ResetOnlineInfo(); } + + // If the original has no beatmaps left, delete the set as well. + if (!original.Beatmaps.Any()) + realm.Remove(original); + + Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); + } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to update beatmap \"{updated}\"", LoggingTarget.Database); + throw; } - - // If the original has no beatmaps left, delete the set as well. - if (!original.Beatmaps.Any()) - realm.Remove(original); }); return first; @@ -359,7 +367,11 @@ namespace osu.Game.Beatmaps { var beatmaps = new List(); - foreach (var file in beatmapSet.Files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) + // stable appears to ignore `.osu` files which are not placed at the top level of the beatmap archive. + // the logic that achieves this is very difficult to make sense of, but appears to be located somewhere around + // https://github.com/peppy/osu-stable-reference/blob/67795dba3c308e7d0493b296149dcb073ca47ecb/osu!/GameplayElements/Beatmaps/BeatmapManager.cs#L207-L208 + // only testing the `/` path separator character is sufficient as `RealmNamedFileUsage`s are normalised to use the front slash unix path separator convention + foreach (var file in beatmapSet.Files.Where(f => !f.Filename.Contains('/') && f.Filename.EndsWith(@".osu", StringComparison.OrdinalIgnoreCase))) { using (var memoryStream = new MemoryStream(Files.Store.Get(file.File.GetStoragePath()))) // we need a memory stream so we can seek { diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 333ec89eab..1f4d370d13 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -125,9 +125,10 @@ namespace osu.Game.Beatmaps /// /// Reset any fetched online linking information (and history). /// - public void ResetOnlineInfo() + public void ResetOnlineInfo(bool resetOnlineId = true) { - OnlineID = -1; + if (resetOnlineId) + OnlineID = -1; LastOnlineUpdate = null; OnlineMD5Hash = string.Empty; if (Status != BeatmapOnlineStatus.LocallyModified) @@ -156,6 +157,12 @@ namespace osu.Game.Beatmaps public bool Equals(IBeatmapInfo? other) => other is BeatmapInfo b && Equals(b); + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return ID.GetHashCode(); + } + public bool AudioEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null @@ -231,8 +238,6 @@ namespace osu.Game.Beatmaps [Obsolete("Use ScoreManager.GetMaximumAchievableComboAsync instead.")] public int? MaxCombo { get; set; } - public int BeatmapVersion; - public BeatmapInfo Clone() => (BeatmapInfo)this.Detach().MemberwiseClone(); public override string ToString() => this.GetDisplayTitle(); diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index a82a288239..d25a171023 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -1,15 +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.Linq; using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Select; namespace osu.Game.Beatmaps { public static class BeatmapInfoExtensions { + /// + /// Given an , update length, BPM and object counts. + /// + public static void UpdateStatisticsFromBeatmap(this BeatmapInfo beatmapInfo, IBeatmap beatmap) + { + beatmapInfo.Length = beatmap.CalculatePlayableLength(); + beatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength(); + beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); + beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; + } + /// /// A user-presentable display title representing this beatmap. /// @@ -51,6 +64,20 @@ namespace osu.Game.Beatmaps private static string getVersionString(IBeatmapInfo beatmapInfo) => string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? string.Empty : $"[{beatmapInfo.DifficultyName}]"; + /// + /// Whether gameplay is allowed for this beatmap with the provided ruleset (via conversion or direct compatibility). + /// + public static bool AllowGameplayWithRuleset(this IBeatmapInfo beatmap, RulesetInfo ruleset, bool allowConversion) + { + if (beatmap.Ruleset.ShortName == ruleset.ShortName) + return true; + + if (allowConversion && beatmap.Ruleset.OnlineID == 0 && ruleset.OnlineID != 0) + return true; + + return false; + } + /// /// Get the beatmap info page URL, or null if unavailable. /// @@ -59,7 +86,7 @@ namespace osu.Game.Beatmaps if (beatmapInfo.OnlineID <= 0 || beatmapInfo.BeatmapSet == null) return null; - return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapInfo.BeatmapSet.OnlineID}#{ruleset?.ShortName ?? beatmapInfo.Ruleset.ShortName}/{beatmapInfo.OnlineID}"; + return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapInfo.BeatmapSet.OnlineID}#{ruleset?.ShortName ?? beatmapInfo.Ruleset.ShortName}/{beatmapInfo.OnlineID}"; } } } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 148bd90f28..08a611e320 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -218,24 +218,37 @@ namespace osu.Game.Beatmaps } /// - /// Delete a beatmap difficulty. + /// Hide a beatmap difficulty. + /// Will fail if all difficulties are about to be hidden. /// /// The beatmap difficulty to hide. - public void Hide(BeatmapInfo beatmapInfo) + public bool Hide(BeatmapInfo beatmapInfo) { - Realm.Run(r => + return Realm.Run(r => { using (var transaction = r.BeginWrite()) { if (!beatmapInfo.IsManaged) beatmapInfo = r.Find(beatmapInfo.ID)!; + if (!CanHide(beatmapInfo)) + return false; + beatmapInfo.Hidden = true; transaction.Commit(); + return true; } }); } + public bool CanHide(BeatmapInfo beatmapInfo) => Realm.Run(r => + { + if (!beatmapInfo.IsManaged) + beatmapInfo = r.Find(beatmapInfo.ID)!; + + return beatmapInfo.BeatmapSet!.Beatmaps.Count(b => !b.Hidden) > 1; + }); + /// /// Restore a beatmap difficulty. /// @@ -271,6 +284,7 @@ namespace osu.Game.Beatmaps /// /// Returns a list of all usable s. + /// IMPORTANT: This should not be used outside of tests. Consider using instead. /// /// A list of available . public List GetAllUsableBeatmapSets() @@ -298,7 +312,21 @@ namespace osu.Game.Beatmaps /// The query. /// The first result for the provided query, or null if no results were found. public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => - r.All().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach()); + r.All().Filter($@"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach()); + + /// + /// Perform a lookup query on available s. + /// Use this overload instead of + /// when Realm is unable to transform an expression to the internal Realm query syntax. + /// + /// The query. + /// The arguments for the query. + /// The first result for the provided query, or null if no results were found. + public BeatmapInfo? QueryBeatmap(string query, params QueryArgument[] arguments) => Realm.Run(r => + r.All() + .Filter($@"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false") + .Filter(query, arguments) + .FirstOrDefault()?.Detach()); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. @@ -475,11 +503,8 @@ namespace osu.Game.Beatmaps beatmapContent.BeatmapInfo = beatmapInfo; // Since now this is a locally-modified beatmap, we also set all relevant flags to indicate this. - // Importantly, the `ResetOnlineInfo()` call must happen before encoding, as online ID is encoded into the `.osu` file, - // which influences the beatmap checksums. beatmapInfo.LastLocalUpdate = DateTimeOffset.Now; beatmapInfo.Status = BeatmapOnlineStatus.LocallyModified; - beatmapInfo.ResetOnlineInfo(); Realm.Write(r => { @@ -533,6 +558,26 @@ namespace osu.Game.Beatmaps } } + public void MarkPlayed(BeatmapInfo beatmapSetInfo) => Realm.Run(r => + { + using var transaction = r.BeginWrite(); + + var beatmap = r.Find(beatmapSetInfo.ID)!; + beatmap.LastPlayed = DateTimeOffset.Now; + + transaction.Commit(); + }); + + public void MarkNotPlayed(BeatmapInfo beatmapSetInfo) => Realm.Run(r => + { + using var transaction = r.BeginWrite(); + + var beatmap = r.Find(beatmapSetInfo.ID)!; + beatmap.LastPlayed = null; + + transaction.Commit(); + }); + #region Implementation of ICanAcceptFiles public Task Import(params string[] paths) => beatmapImporter.Import(paths); @@ -599,6 +644,14 @@ namespace osu.Game.Beatmaps public override bool IsAvailableLocally(BeatmapSetInfo model) => Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID && !s.DeletePending)); + public bool IsAvailableLocally(IBeatmapInfo model) + { + return Realm.Run(r => r.All() + .Filter($@"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false") + .Filter($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", model.OnlineID) + .Any()); + } + #endregion #region Implementation of IPostImports diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 811dc54e16..1603a9848c 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Game.Models; +using osu.Game.Screens.SelectV2; using osu.Game.Users; using osu.Game.Utils; using Realms; @@ -15,10 +17,10 @@ namespace osu.Game.Beatmaps /// A realm model containing metadata for a beatmap. /// /// - /// This is currently stored against each beatmap difficulty, even when it is duplicated. + /// An instance of this object is stored against each beatmap difficulty. /// It is also provided via for convenience and historical purposes. - /// A future effort could see this converted to an or potentially de-duped - /// and shared across multiple difficulties in the same set, if required. + /// Note that accessing the metadata via may result in indeterminate results + /// as metadata can meaningfully differ per beatmap in a set. /// /// Note that difficulty name is not stored in this metadata but in . /// @@ -43,6 +45,13 @@ namespace osu.Game.Beatmaps [JsonProperty(@"tags")] public string Tags { get; set; } = string.Empty; + /// + /// The list of user-voted tags applicable to this beatmap. + /// This information is populated from online sources () + /// and can meaningfully differ between beatmaps of a single set. + /// + public IList UserTags { get; } = null!; + /// /// The time in milliseconds to begin playing the track for preview purposes. /// If -1, the track should begin playing at 40% of its length. diff --git a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs index 41393a8a39..65591abbf6 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs @@ -14,10 +14,12 @@ namespace osu.Game.Beatmaps /// This is a special status given when local changes are made via the editor. /// Once in this state, online status changes should be ignored unless the beatmap is reverted or submitted. /// - [Description("Local")] [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LocallyModified))] + [Description("Local")] LocallyModified = -4, + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.StatusUnknown))] + [Description("Unknown")] None = -3, [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusGraveyard))] diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 59e413d935..0aad55b26d 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -90,6 +90,12 @@ namespace osu.Game.Beatmaps return ID == other.ID; } + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return ID.GetHashCode(); + } + public override string ToString() => Metadata.GetDisplayString(); public bool Equals(IBeatmapSetInfo? other) => other is BeatmapSetInfo b && Equals(b); diff --git a/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs index 8a107ed486..1af0e7a9ee 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs @@ -41,9 +41,9 @@ namespace osu.Game.Beatmaps return null; if (ruleset != null) - return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}#{ruleset.ShortName}"; + return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetInfo.OnlineID}#{ruleset.ShortName}"; - return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}"; + return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetInfo.OnlineID}"; } } } diff --git a/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs b/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs index 8cf43ab320..267e46b0d4 100644 --- a/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs @@ -19,7 +19,7 @@ namespace osu.Game.Beatmaps /// /// The number of nominations required so that the map is eligible for qualification. /// - [JsonProperty(@"required")] - public int Required { get; set; } + [JsonProperty(@"required_meta")] + public BeatmapSetNominationRequiredMeta RequiredMeta { get; set; } = new BeatmapSetNominationRequiredMeta(); } } diff --git a/osu.Game/Beatmaps/BeatmapSetNominationStatusRequiredMeta.cs b/osu.Game/Beatmaps/BeatmapSetNominationStatusRequiredMeta.cs new file mode 100644 index 0000000000..44d31d7b2c --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetNominationStatusRequiredMeta.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 Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + /// + /// Contains information about the number of nominations required for a beatmap set. + /// + public class BeatmapSetNominationRequiredMeta + { + /// + /// The number of nominations required for difficulties of the main ruleset. + /// + [JsonProperty(@"main_ruleset")] + public int MainRuleset { get; set; } + + /// + /// The number of nominations required for difficulties of each non-main ruleset. + /// + [JsonProperty(@"non_main_ruleset")] + public int NonMainRuleset { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapStatistic.cs b/osu.Game/Beatmaps/BeatmapStatistic.cs index 13e0e4ad5e..64e42f3f02 100644 --- a/osu.Game/Beatmaps/BeatmapStatistic.cs +++ b/osu.Game/Beatmaps/BeatmapStatistic.cs @@ -16,7 +16,19 @@ namespace osu.Game.Beatmaps /// public Func CreateIcon; - public string Content; + /// + /// The name of this statistic. + /// public LocalisableString Name; + + /// + /// The text representing the value of this statistic. + /// + public string Content; + + /// + /// The length of a bar which visually represents this statistic's relevance in the beatmap. + /// + public float? BarDisplayLength; } } diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index efb432b84e..64ac69bb07 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -51,7 +51,7 @@ namespace osu.Game.Beatmaps if (lookupScope != MetadataLookupScope.None) metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst); - foreach (var beatmap in beatmapSet.Beatmaps) + foreach (BeatmapInfo beatmap in beatmapSet.Beatmaps) { difficultyCache.Invalidate(beatmap); @@ -63,10 +63,7 @@ namespace osu.Game.Beatmaps var calculator = ruleset.CreateDifficultyCalculator(working); beatmap.StarRating = calculator.Calculate().StarRating; - beatmap.Length = working.Beatmap.CalculatePlayableLength(); - beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); - beatmap.EndTimeObjectCount = working.Beatmap.HitObjects.Count(h => h is IHasDuration); - beatmap.TotalObjectCount = working.Beatmap.HitObjects.Count; + beatmap.UpdateStatisticsFromBeatmap(working.Beatmap); } // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. diff --git a/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs index 364a0f9b4b..d2d9a54fba 100644 --- a/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs +++ b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Platform; +using osu.Game.Extensions; using osu.Game.Online.API; namespace osu.Game.Beatmaps @@ -76,6 +77,8 @@ namespace osu.Game.Beatmaps { beatmapInfo.Status = res.BeatmapStatus; beatmapInfo.Metadata.Author.OnlineID = res.AuthorID; + beatmapInfo.Metadata.UserTags.Clear(); + beatmapInfo.Metadata.UserTags.AddRange(res.UserTags); } } diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index d132b86052..510764694e 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -23,10 +20,12 @@ namespace osu.Game.Beatmaps /// public partial class DifficultyRecommender : Component { + public event Action? StarRatingUpdated; + private readonly LocalUserStatisticsProvider statisticsProvider; [Resolved] - private Bindable gameRuleset { get; set; } + private Bindable gameRuleset { get; set; } = null!; [Resolved] private RulesetStore rulesets { get; set; } = null!; @@ -78,10 +77,18 @@ namespace osu.Game.Beatmaps private void updateMapping(RulesetInfo ruleset, UserStatistics statistics) { - // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 - recommendedDifficultyMapping[ruleset.ShortName] = Math.Pow((double)(statistics.PP ?? 0), 0.4) * 0.195; + // algorithm taken from https://github.com/ppy/osu-web/blob/027026fccc91525e39cee5d2f369f1b343eb1bf1/app/Models/UserStatistics/Model.php#L93-L94 + recommendedDifficultyMapping[ruleset.ShortName] = + ruleset.ShortName == @"taiko" + ? Math.Pow((double)(statistics.PP ?? 0), 0.35) * 0.27 + : Math.Pow((double)(statistics.PP ?? 0), 0.4) * 0.195; + + StarRatingUpdated?.Invoke(); } + public double? GetRecommendedStarRatingFor(RulesetInfo ruleset) + => recommendedDifficultyMapping.TryGetValue(ruleset.ShortName, out double starRating) ? starRating : null; + /// /// Find the recommended difficulty from a selection of available difficulties for the current local user. /// @@ -90,15 +97,14 @@ namespace osu.Game.Beatmaps /// /// A collection of beatmaps to select a difficulty from. /// The recommended difficulty, or null if a recommendation could not be provided. - [CanBeNull] - public BeatmapInfo GetRecommendedBeatmap(IEnumerable beatmaps) + public BeatmapInfo? GetRecommendedBeatmap(IEnumerable beatmaps) { foreach (string r in orderedRulesets) { if (!recommendedDifficultyMapping.TryGetValue(r, out double recommendation)) continue; - BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b => + BeatmapInfo? beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b => { double difference = b.StarRating - recommendation; return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index 599d1b380a..b10ea4fa75 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -19,9 +19,15 @@ namespace osu.Game.Beatmaps.Drawables { public partial class BeatmapSetOnlineStatusPill : CircularContainer, IHasTooltip { - private const double animation_duration = 400; + /// + /// Whether to show as "unknown" instead of fading out. + /// + public bool ShowUnknownStatus { get; init; } - private BeatmapOnlineStatus status; + /// + /// Whether changing status performs transition transforms. + /// + public bool Animated { get; init; } = true; public BeatmapOnlineStatus Status { @@ -34,30 +40,27 @@ namespace osu.Game.Beatmaps.Drawables status = value; if (IsLoaded) - { - AutoSizeDuration = (float)animation_duration; - AutoSizeEasing = Easing.OutQuint; - updateState(); - } } } + private BeatmapOnlineStatus status; + public float TextSize { - get => statusText.Font.Size; - set => statusText.Font = statusText.Font.With(size: value); + init => statusText.Font = statusText.Font.With(size: value); } public MarginPadding TextPadding { - get => statusText.Padding; - set => statusText.Padding = value; + init => statusText.Padding = value; } private readonly OsuSpriteText statusText; private readonly Box background; + private const double animation_duration = 400; + [Resolved] private OsuColour colours { get; set; } = null!; @@ -66,6 +69,7 @@ namespace osu.Game.Beatmaps.Drawables public BeatmapSetOnlineStatusPill() { + AutoSizeAxes = Axes.Both; Masking = true; Alpha = 0; @@ -99,13 +103,29 @@ namespace osu.Game.Beatmaps.Drawables private void updateState() { - if (Status == BeatmapOnlineStatus.None) + double duration = Animated ? animation_duration : 0; + + if (Status == BeatmapOnlineStatus.None && !ShowUnknownStatus) { - this.FadeOut(animation_duration, Easing.OutQuint); + this.FadeOut(duration, Easing.OutQuint); return; } - this.FadeIn(animation_duration, Easing.OutQuint); + // The autosize animation on this component is intended to animate horizontal sizing only. + // To avoid vertical autosize animating from zero to non-zero, only apply the duration + // after we have a valid size. + if (Height > 0) + { + AutoSizeDuration = (float)duration; + AutoSizeEasing = Easing.OutQuint; + } + + this.FadeIn(duration, Easing.OutQuint); + + // Handle the case where transition from hidden to non-hidden may cause + // a fade from a colour that doesn't make sense (due to not being able to see the previous colour). + if (Alpha == 0) + duration = 0; Color4 statusTextColour; @@ -114,8 +134,8 @@ namespace osu.Game.Beatmaps.Drawables else statusTextColour = status == BeatmapOnlineStatus.Graveyard ? colours.GreySeaFoamLight : Color4.Black; - statusText.FadeColour(statusTextColour, animation_duration, Easing.OutQuint); - background.FadeColour(OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeaFoamLighter, animation_duration, Easing.OutQuint); + statusText.FadeColour(statusTextColour, duration, Easing.OutQuint); + background.FadeColour(OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeaFoamLighter, duration, Easing.OutQuint); statusText.Text = Status.GetLocalisableDescription().ToUpper(); } diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index 3aa34a5580..96838bb1ba 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -187,6 +187,11 @@ namespace osu.Game.Beatmaps.Drawables @"1841885 cYsmix - triangles.osz", // winner of https://osu.ppy.sh/home/news/2023-02-01-twin-trials-contest-beatmapping-phase @"1971987 James Landino - Aresene's Bazaar.osz", + // locus 2025 https://osu.ppy.sh/home/news/2025-08-21-locus-2025-results + "2412244 Kry.exe - Rift Walker.osz", + "2412260 Koto Spirit - Locus of Hexagram.osz", + "2412232 Will Stetson - Of Our Time.osz", + "2412292 ArXe - Locus Amoenus (feat. Megurine Luka).osz", }; private static readonly string[] bundled_osu = @@ -292,7 +297,7 @@ namespace osu.Game.Beatmaps.Drawables "1407228 II-L - VANGUARD-1.osz", "1422686 II-L - VANGUARD-2.osz", "1429217 Street - Phi.osz", - "1442235 2ToneDisco x Cosmicosmo - Shoelaces (feat. Puniden).osz", + "1442235 2ToneDisco x Cosmicosmo - Shoelaces (feat. Puniden).osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/157 "1447478 Cres. - End Time.osz", "1449942 m108 - Crescent Sakura.osz", "1463778 MuryokuP - A tree without a branch.osz", @@ -336,8 +341,8 @@ namespace osu.Game.Beatmaps.Drawables "1854710 Blaster & Extra Terra - Spacecraft (Cut Ver.).osz", "1859322 Hino Isuka - Delightness Brightness.osz", "1884102 Maduk - Go (feat. Lachi) (Cut Ver.).osz", - "1884578 Neko Hacker - People People feat. Nanahira.osz", - "1897902 uma vs. Morimori Atsushi - Re: End of a Dream.osz", + "1884578 Neko Hacker - People People feat. Nanahira.osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/266 + "1897902 uma vs. Morimori Atsushi - Re: End of a Dream.osz", // set is not marked as FA, but track is listed in https://osu.ppy.sh/beatmaps/artists/108 "1905582 KINEMA106 - Fly Away (Cut Ver.).osz", "1934686 ARForest - Rainbow Magic!!.osz", "1963076 METAROOM - S.N.U.F.F.Y.osz", @@ -345,7 +350,6 @@ namespace osu.Game.Beatmaps.Drawables "1971951 James Landino - Shiba Paradise.osz", "1972518 Toromaru - Sleight of Hand.osz", "1982302 KINEMA106 - INVITE.osz", - "1983475 KNOWER - The Government Knows.osz", "2010165 Junk - Yellow Smile (bms edit).osz", "2022737 Andora - Euphoria (feat. WaMi).osz", "2025023 tephe - Genjitsu Escape.osz", diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs index 56103c1d6d..8cd0ac965a 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs @@ -7,7 +7,9 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online; @@ -22,7 +24,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards public const float TRANSITION_DURATION = 340; public const float CORNER_RADIUS = 8; - protected const float WIDTH = 345; + public const float WIDTH = 345; public IBindable Expanded { get; } @@ -35,6 +37,18 @@ namespace osu.Game.Beatmaps.Drawables.Cards protected readonly BeatmapDownloadTracker DownloadTracker; + private readonly Bindable preferNoVideo = new BindableBool(); + private InputManager? containingInputManager; + + [Resolved] + private BeatmapSetOverlay? beatmapSetOverlay { get; set; } + + [Resolved] + private BeatmapModelDownloader? beatmaps { get; set; } + + [Resolved] + private OsuGame? game { get; set; } + protected BeatmapCard(APIBeatmapSet beatmapSet, bool allowExpansion = true) : base(HoverSampleSet.Button) { @@ -45,10 +59,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards DownloadTracker = new BeatmapDownloadTracker(beatmapSet); } - [BackgroundDependencyLoader(true)] - private void load(BeatmapSetOverlay? beatmapSetOverlay) + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) { - Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID); + configManager.BindWith(OsuSetting.PreferNoVideo, preferNoVideo); AddInternal(DownloadTracker); } @@ -60,6 +74,30 @@ namespace osu.Game.Beatmaps.Drawables.Cards DownloadTracker.State.BindValueChanged(_ => UpdateState()); Expanded.BindValueChanged(_ => UpdateState(), true); FinishTransforms(true); + + containingInputManager = GetContainingInputManager(); + + if (Action == null) + throw new InvalidOperationException($"An action should be assigned to this {nameof(BeatmapCard)}. To use the default, assign {nameof(DefaultAction)}."); + } + + protected void DefaultAction() + { + if (containingInputManager?.CurrentState.Keyboard.ShiftPressed == true) + { + switch (DownloadTracker.State.Value) + { + case DownloadState.NotDownloaded: + if (!BeatmapSet.Availability.DownloadDisabled) beatmaps?.Download(BeatmapSet, preferNoVideo.Value); + break; + + case DownloadState.LocallyAvailable: + game?.PresentBeatmap(BeatmapSet); + break; + } + } + else + beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID); } protected override bool OnHover(HoverEvent e) @@ -103,7 +141,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards } } - public MenuItem[] ContextMenuItems => new MenuItem[] + public virtual MenuItem[] ContextMenuItems => new MenuItem[] { new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, Action), }; diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.cs index deb56bb281..a57f3e7ce7 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.cs @@ -21,7 +21,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public BeatmapCardContentBackground(IBeatmapSetOnlineInfo onlineInfo) + public BeatmapCardContentBackground(IBeatmapSetOnlineInfo onlineInfo, bool keepLoaded = false) { InternalChildren = new Drawable[] { @@ -29,7 +29,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { RelativeSizeAxes = Axes.Both, }, - cover = new DelayedLoadUnloadWrapper(() => createCover(onlineInfo), 500, 500) + cover = new DelayedLoadUnloadWrapper(() => createCover(onlineInfo), keepLoaded ? 0 : 500, keepLoaded ? double.MaxValue : 1000) { RelativeSizeAxes = Axes.Both, Colour = Colour4.Transparent diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs index ebd0113379..222acbc039 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.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.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Beatmaps.Drawables.Cards.Statistics; 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; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; @@ -42,6 +46,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards : base(beatmapSet, allowExpansion) { content = new BeatmapCardContent(height); + + Action = DefaultAction; } [BackgroundDependencyLoader(true)] @@ -274,8 +280,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards } createStatistics(); - - Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID); } private LocalisableString createArtistText() @@ -321,5 +325,21 @@ namespace osu.Game.Beatmaps.Drawables.Cards buttonContainer.ShowDetails.Value = showDetails; thumbnail.Dimmed.Value = showDetails; } + + public override MenuItem[] ContextMenuItems + { + get + { + var items = base.ContextMenuItems.ToList(); + + foreach (var button in buttonContainer.Buttons) + { + if (button.Enabled.Value) + items.Add(new OsuMenuItem(button.TooltipText.ToSentence(), MenuItemType.Standard, () => button.TriggerClick())); + } + + return items.ToArray(); + } + } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs index a11ef0f95c..ee2f682708 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs @@ -30,17 +30,16 @@ namespace osu.Game.Beatmaps.Drawables.Cards { new BeatmapSetOnlineStatusPill { - AutoSizeAxes = Axes.Both, Status = beatmapSet.Status, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, TextSize = 13f }, - new DifficultySpectrumDisplay(beatmapSet) + new DifficultySpectrumDisplay { + BeatmapSet = beatmapSet, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - DotSize = new Vector2(5, 10) } } }; diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNano.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNano.cs index 4ab2b0c973..c23a03aabe 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNano.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNano.cs @@ -1,13 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; @@ -50,6 +54,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards : base(beatmapSet, false) { content = new BeatmapCardContent(height); + + Action = DefaultAction; } [BackgroundDependencyLoader] @@ -165,5 +171,21 @@ namespace osu.Game.Beatmaps.Drawables.Cards buttonContainer.ShowDetails.Value = showDetails; } + + public override MenuItem[] ContextMenuItems + { + get + { + var items = base.ContextMenuItems.ToList(); + + foreach (var button in buttonContainer.Buttons) + { + if (button.Enabled.Value) + items.Add(new OsuMenuItem(button.TooltipText.ToSentence(), MenuItemType.Standard, () => button.TriggerClick())); + } + + return items.ToArray(); + } + } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs index 724919f3bd..ac9ee94f56 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs @@ -2,14 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Beatmaps.Drawables.Cards.Statistics; 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; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; @@ -43,6 +47,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards : base(beatmapSet, allowExpansion) { content = new BeatmapCardContent(HEIGHT); + + Action = DefaultAction; } [BackgroundDependencyLoader] @@ -291,5 +297,21 @@ namespace osu.Game.Beatmaps.Drawables.Cards statisticsContainer.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); } + + public override MenuItem[] ContextMenuItems + { + get + { + var items = base.ContextMenuItems.ToList(); + + foreach (var button in buttonContainer.Buttons) + { + if (button.Enabled.Value) + items.Add(new OsuMenuItem(button.TooltipText.ToSentence(), MenuItemType.Standard, () => button.TriggerClick())); + } + + return items.ToArray(); + } + } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs index 1f6f638618..4a7054588e 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs @@ -35,11 +35,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public BeatmapCardThumbnail(IBeatmapSetInfo beatmapSetInfo, IBeatmapSetOnlineInfo onlineInfo) + public BeatmapCardThumbnail(IBeatmapSetInfo beatmapSetInfo, IBeatmapSetOnlineInfo onlineInfo, bool keepLoaded = false) { InternalChildren = new Drawable[] { - new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List) + new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List, keepLoaded ? 0 : 500, keepLoaded ? double.MaxValue : 1000) { RelativeSizeAxes = Axes.Both, OnlineInfo = onlineInfo diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs index e78fd651fe..e4bcae281c 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs @@ -43,61 +43,43 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons } } - private float iconSize; + protected SpriteIcon Icon { get; private set; } = null!; - public float IconSize + private Container content = null!; + private Container hover = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - get => iconSize; - set - { - iconSize = value; - Icon.Size = new Vector2(iconSize); - } - } + RelativeSizeAxes = Axes.Both; - protected readonly SpriteIcon Icon; - - protected override Container Content => content; - - private readonly Container content; - private readonly Box hover; - - protected BeatmapCardIconButton() - { - Origin = Anchor.Centre; - Anchor = Anchor.Centre; - - base.Content.Add(content = new Container + Add(content = new Container { RelativeSizeAxes = Axes.Both, Masking = true, - CornerRadius = BeatmapCard.CORNER_RADIUS, Scale = new Vector2(0.8f), Origin = Anchor.Centre, Anchor = Anchor.Centre, Children = new Drawable[] { - hover = new Box + hover = new Container { RelativeSizeAxes = Axes.Both, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, Colour = Color4.White.Opacity(0.1f), Blending = BlendingParameters.Additive, + Child = new Box { RelativeSizeAxes = Axes.Both, } }, Icon = new SpriteIcon { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Scale = new Vector2(1.2f), + Size = new Vector2(14), }, } }); - IconSize = 12; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { IdleColour = colourProvider.Light1; HoverColour = colourProvider.Content1; } @@ -127,8 +109,14 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons bool isHovered = IsHovered && Enabled.Value; hover.FadeTo(isHovered ? 1f : 0f, 500, Easing.OutQuint); - content.ScaleTo(isHovered ? 1 : 0.8f, 500, Easing.OutQuint); + content.ScaleTo(isHovered ? 0.9f : 0.8f, 500, Easing.OutQuint); Icon.FadeColour(isHovered ? HoverColour : IdleColour, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); } + + protected void SetLoading(bool isLoading) + { + Icon.FadeTo(isLoading ? 0.2f : 1, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + Enabled.Value = !isLoading; + } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs index 7f23b46150..96ec9d0731 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs @@ -8,10 +8,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Resources.Localisation.Web; -using osuTK; namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { @@ -23,17 +21,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons private Bindable preferNoVideo = null!; - private readonly LoadingSpinner spinner; - [Resolved] private BeatmapModelDownloader beatmaps { get; set; } = null!; public DownloadButton(APIBeatmapSet beatmapSet) { - Icon.Icon = FontAwesome.Solid.Download; - - Content.Add(spinner = new LoadingSpinner { Size = new Vector2(IconSize) }); - this.beatmapSet = beatmapSet; } @@ -41,6 +33,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons private void load(OsuConfigManager config) { preferNoVideo = config.GetBindable(OsuSetting.PreferNoVideo); + Icon.Icon = FontAwesome.Solid.Download; } protected override void LoadComplete() @@ -64,8 +57,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons case DownloadState.Importing: Action = null; TooltipText = string.Empty; - spinner.Show(); - Icon.Hide(); + SetLoading(true); break; case DownloadState.LocallyAvailable: @@ -84,8 +76,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons Action = () => beatmaps.Download(beatmapSet, preferNoVideo.Value); this.FadeIn(BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); - spinner.Hide(); - Icon.Show(); + SetLoading(false); if (!beatmapSet.HasVideo) TooltipText = BeatmapsetsStrings.PanelDownloadAll; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs index f698185863..f1ec1d1965 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs @@ -53,19 +53,21 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons favouriteRequest?.Cancel(); favouriteRequest = new PostBeatmapFavouriteRequest(beatmapSet.OnlineID, actionType); - Enabled.Value = false; + SetLoading(true); + favouriteRequest.Success += () => { bool favourited = actionType == BeatmapFavouriteAction.Favourite; current.Value = new BeatmapSetFavouriteState(favourited, current.Value.FavouriteCount + (favourited ? 1 : -1)); - Enabled.Value = true; + SetLoading(false); + api.LocalUserState.UpdateFavouriteBeatmapSets(); }; favouriteRequest.Failure += e => { Logger.Error(e, $"Failed to {actionType.ToString().ToLowerInvariant()} beatmap: {e.Message}"); - Enabled.Value = true; + SetLoading(false); }; api.Queue(favouriteRequest); diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.cs index 3df94bf233..3d732b6683 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.cs @@ -16,19 +16,19 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons private readonly Bindable state = new Bindable(); private readonly APIBeatmapSet beatmapSet; + private readonly bool allowNavigationToBeatmap; - public GoToBeatmapButton(APIBeatmapSet beatmapSet) + public GoToBeatmapButton(APIBeatmapSet beatmapSet, bool allowNavigationToBeatmap) { this.beatmapSet = beatmapSet; - - Icon.Icon = FontAwesome.Solid.AngleDoubleRight; - TooltipText = "Go to beatmap"; + this.allowNavigationToBeatmap = allowNavigationToBeatmap; } [BackgroundDependencyLoader(true)] private void load(OsuGame? game) { Action = () => game?.PresentBeatmap(beatmapSet); + Icon.Icon = FontAwesome.Solid.AngleDoubleRight; } protected override void LoadComplete() @@ -41,7 +41,31 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons private void updateState() { - this.FadeTo(state.Value == DownloadState.LocallyAvailable ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + bool available = state.Value == DownloadState.LocallyAvailable; + Enabled.Value = allowNavigationToBeatmap && available; + + float alpha; + + if (available && allowNavigationToBeatmap) + { + TooltipText = "Go to beatmap"; + Enabled.Value = true; + alpha = 1f; + } + else if (available) + { + TooltipText = string.Empty; + Enabled.Value = false; + alpha = 0.3f; + } + else + { + TooltipText = string.Empty; + Enabled.Value = false; + alpha = 0; + } + + this.FadeTo(alpha, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs index a29724032e..8262e787d8 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.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.Bindables; using osu.Framework.Graphics; @@ -29,7 +30,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards set { buttonsExpandedWidth = value; - buttonArea.Width = value; if (IsLoaded) updateState(); } @@ -48,6 +48,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards } } + public IEnumerable Buttons => buttons; + protected override Container Content => mainContent; private readonly Container background; @@ -64,7 +66,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public CollapsibleButtonContainer(APIBeatmapSet beatmapSet) + public CollapsibleButtonContainer(APIBeatmapSet beatmapSet, bool allowNavigationToBeatmap = true, bool keepBackgroundLoaded = false) { downloadTracker = new BeatmapDownloadTracker(beatmapSet); @@ -95,9 +97,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards Child = buttons = new Container { RelativeSizeAxes = Axes.Both, - // Padding of 4 avoids touching the card borders when in the expanded (ie. showing difficulties) state. - // Left override allows the buttons to visually be wider and look better. - Padding = new MarginPadding(4) { Left = 2 }, Children = new BeatmapCardIconButton[] { new FavouriteButton(beatmapSet) @@ -106,7 +105,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.Both, - Height = 0.48f, + Height = 0.5f, }, new DownloadButton(beatmapSet) { @@ -114,16 +113,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards Origin = Anchor.BottomCentre, State = { BindTarget = downloadTracker.State }, RelativeSizeAxes = Axes.Both, - Height = 0.48f, + Height = 0.5f, }, - new GoToBeatmapButton(beatmapSet) - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - State = { BindTarget = downloadTracker.State }, - RelativeSizeAxes = Axes.Both, - Height = 0.48f, - } } } }, @@ -135,7 +126,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards Masking = true, Children = new Drawable[] { - new BeatmapCardContentBackground(beatmapSet) + new BeatmapCardContentBackground(beatmapSet, keepBackgroundLoaded) { RelativeSizeAxes = Axes.Both, Dimmed = { BindTarget = ShowDetails } @@ -152,6 +143,15 @@ namespace osu.Game.Beatmaps.Drawables.Cards } } }; + + buttons.Add(new GoToBeatmapButton(beatmapSet, allowNavigationToBeatmap) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + State = { BindTarget = downloadTracker.State }, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }); } protected override void LoadComplete() @@ -165,6 +165,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards private void updateState() { + buttonArea.Width = buttonsExpandedWidth; + float buttonAreaWidth = ShowDetails.Value ? ButtonsExpandedWidth : ButtonsCollapsedWidth; float mainAreaWidth = Width - buttonAreaWidth; diff --git a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs index 16be57ac95..7cdd50e7ea 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs @@ -20,6 +20,12 @@ namespace osu.Game.Beatmaps.Drawables.Cards set => iconContainer.Size = value; } + public MarginPadding IconPadding + { + get => iconContainer.Padding; + set => iconContainer.Padding = value; + } + private readonly Container iconContainer; protected IconPill(IconUsage icon) diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs index 083f1a353b..01f6fde256 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps.Drawables.Cards.Statistics @@ -12,16 +14,27 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics /// public partial class NominationsStatistic : BeatmapCardStatistic { - private NominationsStatistic(BeatmapSetNominationStatus nominationStatus) + private NominationsStatistic(int current, int required) { Icon = FontAwesome.Solid.ThumbsUp; - Text = nominationStatus.Current.ToLocalisableString(); - TooltipText = BeatmapsStrings.NominationsRequiredText(nominationStatus.Current.ToLocalisableString(), nominationStatus.Required.ToLocalisableString()); + Text = current.ToLocalisableString(); + TooltipText = BeatmapsStrings.NominationsRequiredText(current.ToLocalisableString(), required.ToLocalisableString()); } - public static NominationsStatistic? CreateFor(IBeatmapSetOnlineInfo beatmapSetOnlineInfo) + public static NominationsStatistic? CreateFor(APIBeatmapSet beatmapSet) + { // web does not show nominations unless hypes are also present. // see: https://github.com/ppy/osu-web/blob/8ed7d071fd1d3eaa7e43cf0e4ff55ca2fef9c07c/resources/assets/lib/beatmapset-panel.tsx#L443 - => beatmapSetOnlineInfo.HypeStatus == null || beatmapSetOnlineInfo.NominationStatus == null ? null : new NominationsStatistic(beatmapSetOnlineInfo.NominationStatus); + if (beatmapSet.HypeStatus == null || beatmapSet.NominationStatus == null) + return null; + + int current = beatmapSet.NominationStatus.Current; + int requiredMainRuleset = beatmapSet.NominationStatus.RequiredMeta.MainRuleset; + int requiredNonMainRuleset = beatmapSet.NominationStatus.RequiredMeta.NonMainRuleset; + + int rulesets = beatmapSet.Beatmaps.GroupBy(b => b.Ruleset).Count(); + + return new NominationsStatistic(current, requiredMainRuleset + requiredNonMainRuleset * (rulesets - 1)); + } } } diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 2e7f894d12..92db97475a 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Rulesets; @@ -20,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { - public partial class DifficultyIcon : CompositeDrawable, IHasCustomTooltip, IHasCurrentValue + public partial class DifficultyIcon : CompositeDrawable, IHasCustomTooltip { /// /// Size of this difficulty icon. @@ -46,8 +45,12 @@ namespace osu.Game.Beatmaps.Drawables private readonly Container iconContainer; + [Resolved] + private OsuColour colours { get; set; } = null!; + private readonly BindableWithCurrent difficulty = new BindableWithCurrent(); + // TODO: remove this after old song select is gone. public virtual Bindable Current { get => difficulty.Current; @@ -64,28 +67,19 @@ namespace osu.Game.Beatmaps.Drawables /// An array of mods to account for in the calculations /// An optional ruleset to be used for the icon display, in place of the beatmap's ruleset. public DifficultyIcon(IBeatmapInfo beatmap, IRulesetInfo? ruleset = null, Mod[]? mods = null) - : this(ruleset ?? beatmap.Ruleset) { this.beatmap = beatmap; this.mods = mods; + this.ruleset = ruleset ?? beatmap.Ruleset; Current.Value = new StarDifficulty(beatmap.StarRating, 0); - } - - /// - /// Creates a new without an associated beatmap. - /// - /// The ruleset to be used for the icon display. - public DifficultyIcon(IRulesetInfo ruleset) - { - this.ruleset = ruleset; AutoSizeAxes = Axes.Both; InternalChild = iconContainer = new Container { Size = new Vector2(20f) }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { iconContainer.Children = new Drawable[] { @@ -115,8 +109,18 @@ namespace osu.Game.Beatmaps.Drawables Icon = getRulesetIcon() }, }; + } - Current.BindValueChanged(difficulty => background.Colour = colours.ForStarDifficulty(difficulty.NewValue.Stars), true); + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(difficulty => + { + background.FadeColour(colours.ForStarDifficulty(difficulty.NewValue.Stars), 200); + }, true); + + background.FinishTransforms(); } private Drawable getRulesetIcon() diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs index 8182fe24b2..280185ba17 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs @@ -23,10 +23,6 @@ namespace osu.Game.Beatmaps.Drawables { private OsuSpriteText difficultyName = null!; private StarRatingDisplay starRating = null!; - private OsuSpriteText overallDifficulty = null!; - private OsuSpriteText drainRate = null!; - private OsuSpriteText circleSize = null!; - private OsuSpriteText approachRate = null!; private OsuSpriteText bpm = null!; private OsuSpriteText length = null!; @@ -76,13 +72,6 @@ namespace osu.Game.Beatmaps.Drawables AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(5), - Children = new Drawable[] - { - circleSize = new OsuSpriteText { Font = OsuFont.GetFont(size: 14) }, - drainRate = new OsuSpriteText { Font = OsuFont.GetFont(size: 14) }, - overallDifficulty = new OsuSpriteText { Font = OsuFont.GetFont(size: 14) }, - approachRate = new OsuSpriteText { Font = OsuFont.GetFont(size: 14) }, - } }, miscFillFlowContainer = new FillFlowContainer { @@ -131,21 +120,16 @@ namespace osu.Game.Beatmaps.Drawables double bpmAdjusted = displayedContent.BeatmapInfo.BPM * rate; - BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(displayedContent.BeatmapInfo.Difficulty); - - if (displayedContent.Mods != null) - { - foreach (var mod in displayedContent.Mods.OfType()) - mod.ApplyToDifficulty(originalDifficulty); - } - Ruleset ruleset = displayedContent.Ruleset.CreateInstance(); - BeatmapDifficulty adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); + var beatmapAttributes = ruleset.GetBeatmapAttributesForDisplay(displayedContent.BeatmapInfo, displayedContent.Mods ?? []) + .Select(attr => new OsuSpriteText + { + Font = OsuFont.Style.Caption1, + Text = $@"{attr.Acronym}: {attr.AdjustedValue:0.##}" + }); - circleSize.Text = @"CS: " + adjustedDifficulty.CircleSize.ToString(@"0.##"); - drainRate.Text = @" HP: " + adjustedDifficulty.DrainRate.ToString(@"0.##"); - approachRate.Text = @" AR: " + adjustedDifficulty.ApproachRate.ToString(@"0.##"); - overallDifficulty.Text = @" OD: " + adjustedDifficulty.OverallDifficulty.ToString(@"0.##"); + difficultyFillFlowContainer.Clear(); + difficultyFillFlowContainer.AddRange(beatmapAttributes); TimeSpan lengthTimeSpan = TimeSpan.FromMilliseconds(displayedContent.BeatmapInfo.Length / rate); length.Text = "Length: " + lengthTimeSpan.ToFormattedDuration(); diff --git a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs index 2fb3a8eee4..b7f4d4ca61 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; @@ -18,37 +17,26 @@ namespace osu.Game.Beatmaps.Drawables { public partial class DifficultySpectrumDisplay : CompositeDrawable { - private Vector2 dotSize = new Vector2(4, 8); + private IBeatmapSetInfo? beatmapSet; - public Vector2 DotSize + public IBeatmapSetInfo? BeatmapSet { - get => dotSize; + get => beatmapSet; set { - dotSize = value; + beatmapSet = value; if (IsLoaded) - updateDotDimensions(); + updateDisplay(); } } - private float dotSpacing = 1; + private FillFlowContainer flow = null!; - public float DotSpacing - { - get => dotSpacing; - set - { - dotSpacing = value; + private const int max_difficulties_before_collapsing = 12; - if (IsLoaded) - updateDotDimensions(); - } - } - - private readonly FillFlowContainer flow; - - public DifficultySpectrumDisplay(IBeatmapSetInfo beatmapSet) + [BackgroundDependencyLoader] + private void load() { AutoSizeAxes = Axes.Both; @@ -58,54 +46,80 @@ namespace osu.Game.Beatmaps.Drawables Spacing = new Vector2(10, 0), Direction = FillDirection.Horizontal, }; - - // matching web: https://github.com/ppy/osu-web/blob/d06d8c5e735eb1f48799b1654b528e9a7afb0a35/resources/assets/lib/beatmapset-panel.tsx#L127 - bool collapsed = beatmapSet.Beatmaps.Count() > 12; - - foreach (var rulesetGrouping in beatmapSet.Beatmaps.GroupBy(beatmap => beatmap.Ruleset).OrderBy(group => group.Key)) - flow.Add(new RulesetDifficultyGroup(rulesetGrouping.Key.OnlineID, rulesetGrouping, collapsed)); } protected override void LoadComplete() { base.LoadComplete(); - updateDotDimensions(); + updateDisplay(); } - private void updateDotDimensions() + private void updateDisplay() { foreach (var group in flow) + group.Alpha = 0; + + if (beatmapSet == null) { - group.DotSize = DotSize; - group.DotSpacing = DotSpacing; + foreach (var group in flow) + group.Beatmaps = []; + return; + } + + // matching web: https://github.com/ppy/osu-web/blob/d06d8c5e735eb1f48799b1654b528e9a7afb0a35/resources/assets/lib/beatmapset-panel.tsx#L127 + bool collapsed = beatmapSet.Beatmaps.Count() > max_difficulties_before_collapsing; + + foreach (var rulesetGrouping in beatmapSet.Beatmaps.GroupBy(beatmap => beatmap.Ruleset).OrderBy(group => group.Key)) + { + int rulesetId = rulesetGrouping.Key.OnlineID; + + var group = flow.SingleOrDefault(rg => rg.RulesetId == rulesetId); + + if (group == null) + { + group = new RulesetDifficultyGroup(rulesetId); + flow.Add(group); + flow.SetLayoutPosition(group, rulesetId); + } + + group.Alpha = 1; + group.Beatmaps = rulesetGrouping.ToArray(); + group.Collapsed = collapsed; } } private partial class RulesetDifficultyGroup : FillFlowContainer { - private readonly int rulesetId; - private readonly IEnumerable beatmapInfos; - private readonly bool collapsed; + public readonly int RulesetId; - public RulesetDifficultyGroup(int rulesetId, IEnumerable beatmapInfos, bool collapsed) - { - this.rulesetId = rulesetId; - this.beatmapInfos = beatmapInfos; - this.collapsed = collapsed; - } + private IBeatmapInfo[] beatmaps = []; - public Vector2 DotSize + public IBeatmapInfo[] Beatmaps { set { - foreach (var dot in Children.OfType()) - dot.Size = value; + beatmaps = value.OrderBy(bi => bi.StarRating).ToArray(); + updateDisplay(); } } - public float DotSpacing + private bool collapsed; + + public bool Collapsed { - set => Spacing = new Vector2(value, 0); + get => collapsed; + set + { + collapsed = value; + updateDisplay(); + } + } + + private OsuSpriteText countText = null!; + + public RulesetDifficultyGroup(int rulesetId) + { + RulesetId = rulesetId; } [BackgroundDependencyLoader] @@ -115,52 +129,83 @@ namespace osu.Game.Beatmaps.Drawables Spacing = new Vector2(1, 0); Direction = FillDirection.Horizontal; - var icon = rulesets.GetRuleset(rulesetId)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; + var icon = rulesets.GetRuleset(RulesetId)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; Add(icon.With(i => { i.Size = new Vector2(14); i.Anchor = i.Origin = Anchor.Centre; })); - if (!collapsed) + for (int i = 0; i < max_difficulties_before_collapsing; i++) + Add(new DifficultyDot()); + + Add(countText = new OsuSpriteText { - foreach (var beatmapInfo in beatmapInfos.OrderBy(bi => bi.StarRating)) - Add(new DifficultyDot(beatmapInfo.StarRating)); - } - else + Font = OsuFont.Style.Caption1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding { Bottom = 1 } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + private void updateDisplay() + { + countText.Alpha = collapsed ? 1 : 0; + countText.Text = beatmaps.Length.ToLocalisableString(@"N0"); + + var dots = this.OfType().ToArray(); + + for (int i = 0; i < max_difficulties_before_collapsing; i++) { - Add(new OsuSpriteText + var dot = dots[i]; + + if (collapsed || i >= beatmaps.Length) { - Text = beatmapInfos.Count().ToLocalisableString(@"N0"), - Font = OsuFont.Default.With(size: 12), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding { Bottom = 1 } - }); + dot.Alpha = 0; + continue; + } + + dot.Alpha = 1; + dot.StarDifficulty = beatmaps[i].StarRating; } } } - private partial class DifficultyDot : CircularContainer + private partial class DifficultyDot : Circle { - private readonly double starDifficulty; + private double starDifficulty; - public DifficultyDot(double starDifficulty) + public double StarDifficulty { - this.starDifficulty = starDifficulty; + get => starDifficulty; + set + { + starDifficulty = value; + updateColour(); + } } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Anchor = Origin = Anchor.Centre; - Masking = true; + [Resolved] + private OsuColour colours { get; set; } = null!; - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.ForStarDifficulty(starDifficulty) - }; + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(5, 10); + Anchor = Origin = Anchor.Centre; + + updateColour(); + } + + private void updateColour() + { + Colour = colours.ForStarDifficulty(starDifficulty); } } } diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index 55ef6f705e..c9f2f8a4b1 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -5,7 +5,6 @@ using System; 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; @@ -14,6 +13,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -37,9 +37,13 @@ namespace osu.Game.Beatmaps.Drawables set => current.Current = value; } - private readonly Bindable displayedStars = new BindableDouble(); + /// + /// The difficulty colour currently displayed. + /// Can be used to have other components match the spectrum animation. + /// + public Color4 DisplayedDifficultyColour => background.Colour; - private readonly Container textContainer; + private readonly Bindable displayedStars = new BindableDouble(); /// /// The currently displayed stars of this display wrapped in a bindable. @@ -119,19 +123,14 @@ namespace osu.Game.Beatmaps.Drawables Size = new Vector2(8f), }, Empty(), - textContainer = new Container + starsText = new OsuSpriteText { - AutoSizeAxes = Axes.Y, - Child = starsText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Margin = new MarginPadding { Bottom = 1.5f }, - // todo: this should be size: 12f, but to match up with the design, it needs to be 14.4f - // see https://github.com/ppy/osu-framework/issues/3271. - Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold), - Shadow = false, - }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 1.5f }, + Spacing = new Vector2(-1.4f), + Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold, fixedWidth: true), + Shadow = false, }, } } @@ -147,7 +146,8 @@ namespace osu.Game.Beatmaps.Drawables Current.BindValueChanged(c => { if (animated) - this.TransformBindableTo(displayedStars, c.NewValue.Stars, 750, Easing.OutQuint); + // Animation roughly matches `StarCounter`'s implementation. + this.TransformBindableTo(displayedStars, c.NewValue.Stars, 100 + 80 * Math.Abs(c.NewValue.Stars - c.OldValue.Stars), Easing.OutQuint); else displayedStars.Value = c.NewValue.Stars; }); @@ -156,17 +156,12 @@ namespace osu.Game.Beatmaps.Drawables displayedStars.BindValueChanged(s => { - starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.ToLocalisableString("0.00"); + starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.FormatStarRating(); 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); - - // In order to avoid autosize throwing the width of these displays all over the place, - // let's lock in some sane defaults for the text width based on how many digits we're - // displaying. - textContainer.Width = 24 + Math.Max(starsText.Text.ToString().Length - 4, 0) * 6; + starIcon.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); + starsText.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); }, true); } } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs index 5bce472613..a03ee64ef4 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs @@ -50,8 +50,7 @@ namespace osu.Game.Beatmaps.Drawables protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, timeBeforeUnload) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, }; protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model) diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index 35067f4055..5dc73d8679 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -30,8 +30,8 @@ namespace osu.Game.Beatmaps { Metadata = new BeatmapMetadata { - Artist = "please load a beatmap!", - Title = "no beatmaps available!" + Artist = "please select or load a beatmap!", + Title = "no beatmap selected!" }, BeatmapSet = new BeatmapSetInfo(), Difficulty = new BeatmapDifficulty diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 153db6d6b9..e3ac0e1a3d 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -36,6 +36,11 @@ namespace osu.Game.Beatmaps.Formats /// public const double CONTROL_POINT_LENIENCY = 5; + /// + /// The maximum allowed number of keys in mania beatmaps. + /// + public const int MAX_MANIA_KEY_COUNT = 18; + internal static RulesetStore? RulesetStore; private Beatmap beatmap = null!; @@ -51,7 +56,7 @@ namespace osu.Game.Beatmaps.Formats } /// - /// Whether or not beatmap or runtime offsets should be applied. Defaults on; only disable for testing purposes. + /// Whether beatmap or runtime offsets should be applied. Defaults on; only disable for testing purposes. /// public bool ApplyOffsets = true; @@ -79,7 +84,7 @@ namespace osu.Game.Beatmaps.Formats protected override void ParseStreamInto(LineBufferedReader stream, Beatmap beatmap) { this.beatmap = beatmap; - this.beatmap.BeatmapInfo.BeatmapVersion = FormatVersion; + this.beatmap.BeatmapVersion = FormatVersion; parser = new ConvertHitObjectParser(getOffsetTime(), FormatVersion); ApplyLegacyDefaults(this.beatmap); @@ -116,7 +121,7 @@ namespace osu.Game.Beatmaps.Formats // mania uses "circle size" for key count, thus different allowable range difficulty.CircleSize = beatmap.BeatmapInfo.Ruleset.OnlineID != 3 ? Math.Clamp(difficulty.CircleSize, 0, 10) - : Math.Clamp(difficulty.CircleSize, 1, 18); + : Math.Clamp(difficulty.CircleSize, 1, MAX_MANIA_KEY_COUNT); difficulty.OverallDifficulty = Math.Clamp(difficulty.OverallDifficulty, 0, 10); difficulty.ApproachRate = Math.Clamp(difficulty.ApproachRate, 0, 10); @@ -193,6 +198,10 @@ namespace osu.Game.Beatmaps.Formats internal static void ApplyLegacyDefaults(Beatmap beatmap) { beatmap.WidescreenStoryboard = false; + // in a perfect world this would throw if osu! ruleset couldn't be found, + // but unfortunately there are "legitimate" cases where it's not there (i.e. ruleset test projects), + // so attempt to trudge on with whatever it is that's in `BeatmapInfo` if the lookup fails. + beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(0) ?? beatmap.BeatmapInfo.Ruleset; } protected override void ParseLine(Beatmap beatmap, Section section, string line) diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 6c855e1346..24976717c1 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -319,11 +319,25 @@ namespace osu.Game.Beatmaps.Formats SampleControlPoint createSampleControlPointFor(double time, IList samples) { int volume = samples.Max(o => o.Volume); - int customIndex = samples.Any(o => o is ConvertHitObjectParser.LegacyHitSampleInfo) - ? samples.OfType().Max(o => o.CustomSampleBank) - : -1; + string bank = samples.Where(s => s.Name == HitSampleInfo.HIT_NORMAL).Select(s => s.Bank).FirstOrDefault() + ?? samples.Select(s => s.Bank).First(); - return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = time, SampleVolume = volume, CustomSampleBank = customIndex }; + int customIndex = samples.Max(s => + { + switch (s) + { + case ConvertHitObjectParser.LegacyHitSampleInfo legacy: + return legacy.CustomSampleBank; + + default: + if (int.TryParse(s.Suffix, out int index)) + return index; + + return s.UseBeatmapSamples ? 1 : -1; + } + }); + + return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = time, SampleVolume = volume, SampleBank = bank, CustomSampleBank = customIndex }; } } @@ -349,7 +363,7 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[Colours]"); - for (int i = 0; i < colours.Count; i++) + for (int i = 0; i < Math.Min(colours.Count, LegacyBeatmapDecoder.MAX_COMBO_COLOUR_COUNT); i++) { var comboColour = colours[i]; @@ -447,60 +461,31 @@ namespace osu.Game.Beatmaps.Formats private void addPathData(TextWriter writer, IHasPath pathData, Vector2 position) { - PathType? lastType = null; - for (int i = 0; i < pathData.Path.ControlPoints.Count; i++) { PathControlPoint point = pathData.Path.ControlPoints[i]; + // Note that lazer's encoding format supports specifying multiple curve types for a slider path, which is not supported by stable. + // Backwards compatibility with stable is handled by `LegacyBeatmapExporter` and `BezierConverter.ConvertToModernBezier()`. 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 != lastType || point.Type == PathType.PERFECT_CURVE || i == pathData.Path.ControlPoints.Count - 1; - - // 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) + switch (point.Type?.Type) { - // 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; - Vector2 p2 = position + pathData.Path.ControlPoints[i - 2].Position; + case SplineType.BSpline: + writer.Write(point.Type.Value.Degree > 0 ? $"B{point.Type.Value.Degree}|" : "B|"); + break; - if ((int)p1.X == (int)p2.X && (int)p1.Y == (int)p2.Y) - needsExplicitSegment = true; - } + case SplineType.Catmull: + writer.Write("C|"); + break; - if (needsExplicitSegment) - { - switch (point.Type?.Type) - { - case SplineType.BSpline: - writer.Write(point.Type.Value.Degree > 0 ? $"B{point.Type.Value.Degree}|" : "B|"); - break; + case SplineType.PerfectCurve: + writer.Write("P|"); + break; - case SplineType.Catmull: - writer.Write("C|"); - break; - - case SplineType.PerfectCurve: - writer.Write("P|"); - break; - - case SplineType.Linear: - writer.Write("L|"); - break; - } - - lastType = point.Type; - } - else - { - // New segment with the same type - duplicate the control point - writer.Write(FormattableString.Invariant($"{position.X + point.Position.X}:{position.Y + point.Position.Y}|")); + case SplineType.Linear: + writer.Write("L|"); + break; } } @@ -559,7 +544,7 @@ namespace osu.Game.Beatmaps.Formats if (!banksOnly) { int customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name))); - string sampleFilename = samples.FirstOrDefault(s => string.IsNullOrEmpty(s.Name))?.LookupNames.First() ?? string.Empty; + string sampleFilename = samples.FirstOrDefault(s => s is ConvertHitObjectParser.FileHitSampleInfo)?.LookupNames.First() ?? string.Empty; int volume = samples.FirstOrDefault()?.Volume ?? 100; // We want to ignore custom sample banks and volume when not encoding to the mania game mode, diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index ca4fadf458..6fb762b9ee 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -16,8 +16,12 @@ namespace osu.Game.Beatmaps.Formats public abstract class LegacyDecoder : Decoder where T : new() { + // If this is updated, a new release of `osu-server-beatmap-submission` is required with updated packages. + // See usage at https://github.com/ppy/osu-server-beatmap-submission/blob/master/osu.Server.BeatmapSubmission/Services/BeatmapPackageParser.cs#L96-L97. public const int LATEST_VERSION = 14; + public const int MAX_COMBO_COLOUR_COUNT = 8; + /// /// The .osu format (beatmap) version. /// @@ -126,7 +130,9 @@ namespace osu.Game.Beatmaps.Formats string[] split = pair.Value.Split(','); Color4 colour = convertSettingStringToColor4(split, allowAlpha, pair); - bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal); + bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal) + && int.TryParse(pair.Key[5..], out int comboIndex) + && comboIndex >= 1 && comboIndex <= MAX_COMBO_COLOUR_COUNT; if (isCombo) { diff --git a/osu.Game/Beatmaps/FramedBeatmapClock.cs b/osu.Game/Beatmaps/FramedBeatmapClock.cs index af7be235fc..8ce0db6e7b 100644 --- a/osu.Game/Beatmaps/FramedBeatmapClock.cs +++ b/osu.Game/Beatmaps/FramedBeatmapClock.cs @@ -59,7 +59,10 @@ namespace osu.Game.Beatmaps // An interpolating clock is used to ensure precise time values even when the host audio subsystem is not reporting // high precision times (on windows there's generally only 5-10ms reporting intervals, as an example). - interpolatedTrack = new InterpolatingFramedClock(decoupledTrack); + interpolatedTrack = new InterpolatingFramedClock(decoupledTrack) + { + DriftRecoveryHalfLife = 80, + }; if (applyOffsets) { diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 826d4e19a7..868befe097 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -32,7 +32,7 @@ namespace osu.Game.Beatmaps /// /// This beatmap's difficulty settings. /// - public BeatmapDifficulty Difficulty { get; set; } + BeatmapDifficulty Difficulty { get; set; } /// /// The control points in this beatmap. @@ -109,6 +109,8 @@ namespace osu.Game.Beatmaps int[] Bookmarks { get; internal set; } + int BeatmapVersion { get; } + /// /// Creates a shallow-clone of this beatmap and returns it. /// @@ -161,7 +163,7 @@ namespace osu.Game.Beatmaps /// /// Find the total milliseconds between the first and last hittable objects, excluding any break time. /// - public static double CalculateDrainLength(this IBeatmap beatmap) => CalculatePlayableLength(beatmap.HitObjects) - beatmap.TotalBreakTime; + public static double CalculateDrainLength(this IBeatmap beatmap) => Math.Max(CalculatePlayableLength(beatmap.HitObjects) - beatmap.TotalBreakTime, 0); /// /// Find the timestamps in milliseconds of the start and end of the playable region. diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs index 48f6564084..2dd73a2541 100644 --- a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs @@ -92,8 +92,8 @@ namespace osu.Game.Beatmaps /// /// /// Value to which the difficulty value maps in the specified range. - static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range) - => DifficultyRange(difficulty, range.od0, range.od5, range.od10); + static double DifficultyRange(double difficulty, DifficultyRange range) + => DifficultyRange(difficulty, range.Min, range.Mid, range.Max); /// /// Inverse function to . @@ -110,5 +110,23 @@ namespace osu.Game.Beatmaps ? (difficultyValue - diff5) / (diff10 - diff5) * 5 + 5 : (difficultyValue - diff5) / (diff5 - diff0) * 5 + 5; } + + /// + /// Inverse function to . + /// Maps a value returned by the function above back to the difficulty that produced it. + /// + /// The difficulty-dependent value to be unmapped. + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// Value to which the difficulty value maps in the specified range. + static double InverseDifficultyRange(double difficultyValue, DifficultyRange range) + => InverseDifficultyRange(difficultyValue, range.Min, range.Mid, range.Max); } + + /// + /// Represents a piecewise-linear difficulty curve for a given gameplay quantity. + /// + /// 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. + public record struct DifficultyRange(double Min, double Mid, double Max); } diff --git a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs index 6fab66bf70..ca3f7cc354 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs @@ -13,6 +13,8 @@ namespace osu.Game.Beatmaps.Legacy NewCombo = 1 << 2, Spinner = 1 << 3, ComboOffset = (1 << 4) | (1 << 5) | (1 << 6), - Hold = 1 << 7 + Hold = 1 << 7, + + ObjectTypes = Circle | Slider | Spinner | Hold } } diff --git a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs index 66fad6c8d8..c591dac36f 100644 --- a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs @@ -46,7 +46,7 @@ namespace osu.Game.Beatmaps this.storage = storage; if (shouldFetchCache()) - prepareLocalCache(); + FetchCache(); } private bool shouldFetchCache() @@ -104,15 +104,39 @@ namespace osu.Game.Beatmaps switch (getCacheVersion(db)) { - case 1: - // will eventually become irrelevant due to the monthly recycling of local caches - // can be removed 20250221 - return queryCacheVersion1(db, beatmapInfo, out onlineMetadata); - case 2: + // can be removed 20260123 return queryCacheVersion2(db, beatmapInfo, out onlineMetadata); + + case 3: + return queryCacheVersion3(db, beatmapInfo, out onlineMetadata); } } + + onlineMetadata = null; + return false; + } + catch (SqliteException sqliteException) + { + onlineMetadata = null; + + // There have been cases where the user's local database is corrupt. + // Let's attempt to identify these cases and re-initialise the local cache. + switch (sqliteException.SqliteErrorCode) + { + case 26: // SQLITE_NOTADB + case 11: // SQLITE_CORRUPT + // only attempt purge & re-download if there is no other refetch in progress + if (cacheDownloadRequest != null) + return false; + + tryPurgeCache(); + FetchCache(); + return false; + } + + logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} failed with unhandled sqlite error {sqliteException}."); + return false; } catch (Exception ex) { @@ -120,15 +144,28 @@ namespace osu.Game.Beatmaps onlineMetadata = null; return false; } + } - onlineMetadata = null; - return false; + private void tryPurgeCache() + { + log(@"Local metadata cache is corrupted; attempting purge."); + + try + { + File.Delete(storage.GetFullPath(cache_database_name)); + } + catch (Exception ex) + { + log($@"Failed to purge local metadata cache: {ex}"); + } + + log(@"Local metadata cache purged due to corruption."); } private SqliteConnection getConnection() => new SqliteConnection(string.Concat(@"Data Source=", storage.GetFullPath(@"online.db", true))); - private void prepareLocalCache() + public Task FetchCache() { bool isRefetch = storage.Exists(cache_database_name); @@ -155,6 +192,13 @@ namespace osu.Game.Beatmaps { try { + // `SqliteConnection` by default uses pooling. + // disposing an `SqliteConnection` is not enough to get `Microsoft.Data.Sqlite` to close the database file. + // this means that overwriting the file may fail if the pools are not cleared before trying. + // this fails especially loudly on Windows because of Windows file delete semantics being exclusive-write + // rather than Unix's "file is marked for deletion after last reader closes the fd". + SqliteConnection.ClearAllPools(); + using (var stream = File.OpenRead(cacheDownloadRequest.Filename)) using (var outStream = File.OpenWrite(cacheFilePath)) { @@ -181,7 +225,7 @@ namespace osu.Game.Beatmaps } }; - Task.Run(async () => + return Task.Run(async () => { try { @@ -194,12 +238,20 @@ namespace osu.Game.Beatmaps }); } - public int GetCacheVersion() + public bool IsAtLeastVersion(int version) { - using (var connection = getConnection()) + try { - connection.Open(); - return getCacheVersion(connection); + using (var connection = getConnection()) + { + connection.Open(); + return getCacheVersion(connection) >= version; + } + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 26 || ex.SqliteErrorCode == 11) // SQLITE_NOTADB, SQLITE_CORRUPT + { + // if the database is corrupted then return `false` as the consumer may want to just refetch the db themselves + return false; } } @@ -232,40 +284,14 @@ namespace osu.Game.Beatmaps } } - private bool queryCacheVersion1(SqliteConnection db, BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) + public DateTime? GetCacheFetchDate() { - Debug.Assert(beatmapInfo.BeatmapSet != null); + string path = storage.GetFullPath(cache_database_name); + var file = new FileInfo(path); + if (!file.Exists) + return null; - using var cmd = db.CreateCommand(); - - cmd.CommandText = - @"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR filename = @Path"; - - cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); - cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); - - using var reader = cmd.ExecuteReader(); - - if (reader.Read()) - { - logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} (cache version 1)."); - - onlineMetadata = new OnlineBeatmapMetadata - { - BeatmapSetID = reader.GetInt32(0), - BeatmapID = reader.GetInt32(1), - BeatmapStatus = (BeatmapOnlineStatus)reader.GetByte(2), - BeatmapSetStatus = (BeatmapOnlineStatus)reader.GetByte(2), - AuthorID = reader.GetInt32(3), - MD5Hash = reader.GetString(4), - LastUpdated = reader.GetDateTimeOffset(5), - // TODO: DateSubmitted and DateRanked are not provided by local cache in this version. - }; - return true; - } - - onlineMetadata = null; - return false; + return file.LastWriteTime; } private bool queryCacheVersion2(SqliteConnection db, BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) @@ -279,8 +305,12 @@ namespace osu.Game.Beatmaps SELECT `b`.`beatmapset_id`, `b`.`beatmap_id`, `b`.`approved`, `b`.`user_id`, `b`.`checksum`, `b`.`last_update`, `s`.`submit_date`, `s`.`approved_date` FROM `osu_beatmaps` AS `b` JOIN `osu_beatmapsets` AS `s` ON `s`.`beatmapset_id` = `b`.`beatmapset_id` - WHERE `b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path + WHERE (`b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path) + AND `b`.`approved` in (1, 2, 4) """; + // approved conditional can theoretically be removed as it was fixed in + // https://github.com/ppy/osu-onlinedb-generator/commit/489ac000775c3ff63bc914efb83cad0f6fbde261 + // but it's also safe to leave it (should not affect performance). cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); @@ -310,6 +340,63 @@ namespace osu.Game.Beatmaps return false; } + private bool queryCacheVersion3(SqliteConnection db, BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) + { + Debug.Assert(beatmapInfo.BeatmapSet != null); + + using (var cmd = db.CreateCommand()) + { + cmd.CommandText = + """ + SELECT `b`.`beatmapset_id`, `b`.`beatmap_id`, `b`.`approved`, `b`.`user_id`, `b`.`checksum`, `b`.`last_update`, `s`.`submit_date`, `s`.`approved_date` + FROM `osu_beatmaps` AS `b` + JOIN `osu_beatmapsets` AS `s` ON `s`.`beatmapset_id` = `b`.`beatmapset_id` + WHERE (`b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path) + """; + + cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); + cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); + + using (var reader = cmd.ExecuteReader()) + { + if (!reader.Read()) + { + onlineMetadata = null; + return false; + } + + logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} (cache version 3)."); + + onlineMetadata = new OnlineBeatmapMetadata + { + BeatmapSetID = reader.GetInt32(0), + BeatmapID = reader.GetInt32(1), + BeatmapStatus = (BeatmapOnlineStatus)reader.GetByte(2), + BeatmapSetStatus = (BeatmapOnlineStatus)reader.GetByte(2), + AuthorID = reader.GetInt32(3), + MD5Hash = reader.GetString(4), + LastUpdated = reader.GetDateTimeOffset(5), + DateSubmitted = reader.GetDateTimeOffset(6), + DateRanked = reader.GetDateTimeOffset(7), + }; + } + } + + using (var tagsCommand = db.CreateCommand()) + { + tagsCommand.CommandText = "SELECT `name` FROM `tags` WHERE `id` IN (SELECT `tag_id` FROM `beatmap_tags` WHERE `beatmap_id` = @BeatmapID)"; + tagsCommand.Parameters.Add(new SqliteParameter(@"@BeatmapID", onlineMetadata.BeatmapID)); + + using (var tagsReader = tagsCommand.ExecuteReader()) + { + while (tagsReader.Read()) + onlineMetadata.UserTags.Add(tagsReader.GetString(0)); + } + } + + return true; + } + private static void log(string message) => Logger.Log($@"[{nameof(LocalCachedBeatmapMetadataSource)}] {message}", LoggingTarget.Database); diff --git a/osu.Game/Beatmaps/MetadataUtils.cs b/osu.Game/Beatmaps/MetadataUtils.cs index 89c821c16c..1d2a3b5d01 100644 --- a/osu.Game/Beatmaps/MetadataUtils.cs +++ b/osu.Game/Beatmaps/MetadataUtils.cs @@ -15,7 +15,7 @@ namespace osu.Game.Beatmaps /// Returns if the character can be used in and fields. /// Characters not matched by this method can be placed in and . /// - public static bool IsRomanised(char c) => c <= 0xFF; + public static bool IsRomanised(char c) => char.IsAscii(c) && !char.IsControl(c); /// /// Returns if the string can be used in and fields. diff --git a/osu.Game/Beatmaps/OnlineBeatmapMetadata.cs b/osu.Game/Beatmaps/OnlineBeatmapMetadata.cs index 8640883ca1..2acadde352 100644 --- a/osu.Game/Beatmaps/OnlineBeatmapMetadata.cs +++ b/osu.Game/Beatmaps/OnlineBeatmapMetadata.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; namespace osu.Game.Beatmaps { @@ -57,5 +58,10 @@ namespace osu.Game.Beatmaps /// The date when this metadata was last updated. /// public DateTimeOffset LastUpdated { get; init; } + + /// + /// The list of tags that users have assigned to this beatmap. + /// + public List UserTags { get; } = []; } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 890a969415..4ea26b46f8 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -124,18 +124,16 @@ namespace osu.Game.Beatmaps Track.Looping = looping; Track.RestartPoint = Metadata.PreviewTime; - if (Track.RestartPoint == -1) + if (!Track.IsLoaded) { - if (!Track.IsLoaded) - { - // force length to be populated (https://github.com/ppy/osu-framework/issues/4202) - Track.Seek(Track.CurrentTime); - } - - Track.RestartPoint = 0.4f * Track.Length; + // force length to be populated (https://github.com/ppy/osu-framework/issues/4202) + Track.Seek(Track.CurrentTime); } - Track.RestartPoint += offsetFromPreviewPoint; + if (Track.RestartPoint < 0 || Track.RestartPoint > Track.Length) + Track.RestartPoint = 0.4f * Track.Length; + + Track.RestartPoint = Math.Clamp(Track.RestartPoint + offsetFromPreviewPoint, 0, Track.Length); } /// @@ -203,6 +201,8 @@ namespace osu.Game.Beatmaps { try { + // TODO: This is a touch expensive and can become an issue if being accessed every Update call. + // Optimally we would not involve the async flow if things are already loaded. return loadBeatmapAsync().GetResultSafely(); } catch (AggregateException ae) @@ -211,12 +211,12 @@ namespace osu.Game.Beatmaps if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException) return null; - Logger.Error(ae, "Beatmap failed to load"); + Logger.Error(ae, $"Beatmap failed to load ({BeatmapInfo})"); return null; } catch (Exception e) { - Logger.Error(e, "Beatmap failed to load"); + Logger.Error(e, $"Beatmap failed to load ({BeatmapInfo})"); return null; } } @@ -233,11 +233,18 @@ namespace osu.Game.Beatmaps // Todo: Handle cancellation during beatmap parsing var b = GetBeatmap() ?? new Beatmap(); - // The original beatmap version needs to be preserved as the database doesn't contain it - BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; - - // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) - b.BeatmapInfo = BeatmapInfo; + // Copy across values of key properties for which the database-backed model has data that the decoded beatmap isn't going to. + b.BeatmapInfo.ID = BeatmapInfo.ID; + b.BeatmapInfo.UserSettings = BeatmapInfo.UserSettings; + b.BeatmapInfo.BeatmapSet = BeatmapInfo.BeatmapSet; + b.BeatmapInfo.Status = BeatmapInfo.Status; + b.BeatmapInfo.OnlineID = BeatmapInfo.OnlineID; + b.BeatmapInfo.OnlineMD5Hash = BeatmapInfo.OnlineMD5Hash; + b.BeatmapInfo.LastLocalUpdate = BeatmapInfo.LastLocalUpdate; + b.BeatmapInfo.LastOnlineUpdate = BeatmapInfo.LastOnlineUpdate; + b.BeatmapInfo.LastPlayed = BeatmapInfo.LastPlayed; + b.BeatmapInfo.EditorTimestamp = BeatmapInfo.EditorTimestamp; + b.BeatmapInfo.StarRating = BeatmapInfo.StarRating; // this could be recomputed in the decoding process but it's a bit annoying to do. return b; }, loadCancellationSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 8af74d11d8..9957935977 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -20,6 +20,7 @@ using osu.Framework.Platform; using osu.Framework.Statistics; using osu.Game.Beatmaps.Formats; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Storyboards; @@ -89,7 +90,7 @@ namespace osu.Game.Beatmaps public virtual WorkingBeatmap GetWorkingBeatmap([CanBeNull] BeatmapInfo beatmapInfo) { - if (beatmapInfo?.BeatmapSet == null) + if (beatmapInfo == null || ReferenceEquals(beatmapInfo, DefaultBeatmap.BeatmapInfo)) return DefaultBeatmap; lock (workingCache) @@ -152,14 +153,25 @@ namespace osu.Game.Beatmaps return null; } - if (stream.ComputeMD5Hash() != BeatmapInfo.MD5Hash) + string streamMD5 = stream.ComputeMD5Hash(); + string streamSHA2 = stream.ComputeSHA2Hash(); + + if (streamMD5 != BeatmapInfo.MD5Hash) { Logger.Log($"Beatmap failed to load (file {BeatmapInfo.Path} does not have the expected hash).", level: LogLevel.Error); return null; } using (var reader = new LineBufferedReader(stream)) - return Decoder.GetDecoder(reader).Decode(reader); + { + var beatmap = Decoder.GetDecoder(reader).Decode(reader); + + beatmap.BeatmapInfo.MD5Hash = streamMD5; + beatmap.BeatmapInfo.Hash = streamSHA2; + beatmap.BeatmapInfo.UpdateStatisticsFromBeatmap(beatmap); + + return beatmap; + } } catch (Exception e) { @@ -330,27 +342,10 @@ namespace osu.Game.Beatmaps { // Matches stable implementation, because it's probably simpler than trying to do anything else. // This may need to be reconsidered after we begin storing storyboards in the new editor. - return windowsFilenameStrip( - (metadata.Artist.Length > 0 ? metadata.Artist + @" - " + metadata.Title : Path.GetFileNameWithoutExtension(metadata.AudioFile)) - + (metadata.Author.Username.Length > 0 ? @" (" + metadata.Author.Username + @")" : string.Empty) - + @".osb"); - - string windowsFilenameStrip(string entry) - { - // Inlined from Path.GetInvalidFilenameChars() to ensure the windows characters are used (to match stable). - char[] invalidCharacters = - { - '\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', - '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', '\x10', '\x11', '\x12', - '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', - '\x1E', '\x1F', '\x22', '\x3C', '\x3E', '\x7C', ':', '*', '?', '\\', '/' - }; - - foreach (char c in invalidCharacters) - entry = entry.Replace(c.ToString(), string.Empty); - - return entry; - } + string baseFilename = (metadata.Artist.Length > 0 ? metadata.Artist + @" - " + metadata.Title : Path.GetFileNameWithoutExtension(metadata.AudioFile)) + + (metadata.Author.Username.Length > 0 ? @" (" + metadata.Author.Username + @")" : string.Empty) + + @".osb"; + return baseFilename.GetValidFilename(); } } } diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs index 1e47aff3ec..2f9e94fef7 100644 --- a/osu.Game/Collections/CollectionDropdown.cs +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -263,11 +264,11 @@ namespace osu.Game.Collections { Debug.Assert(collection != null); - collection.PerformWrite(c => + Task.Run(() => collection.PerformWrite(c => { if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); - }); + })); } protected override Drawable CreateContent() => (Content)base.CreateContent(); diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs index 49262ed917..7dfa45379a 100644 --- a/osu.Game/Collections/CollectionFilterMenuItem.cs +++ b/osu.Game/Collections/CollectionFilterMenuItem.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Localisation; using osu.Game.Database; +using osu.Game.Localisation; namespace osu.Game.Collections { @@ -20,7 +22,7 @@ namespace osu.Game.Collections /// /// The name of the collection. /// - public string CollectionName { get; } + public LocalisableString CollectionName { get; } /// /// Creates a new . @@ -32,7 +34,7 @@ namespace osu.Game.Collections Collection = collection; } - protected CollectionFilterMenuItem(string name) + protected CollectionFilterMenuItem(LocalisableString name) { CollectionName = name; } @@ -53,7 +55,7 @@ namespace osu.Game.Collections public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem { public AllBeatmapsCollectionFilterMenuItem() - : base("All beatmaps") + : base(CollectionsStrings.AllBeatmaps) { } @@ -65,7 +67,7 @@ namespace osu.Game.Collections public class ManageCollectionsFilterMenuItem : CollectionFilterMenuItem { public ManageCollectionsFilterMenuItem() - : base("Manage collections...") + : base(CollectionsStrings.ManageCollections) { } diff --git a/osu.Game/Collections/CollectionToggleMenuItem.cs b/osu.Game/Collections/CollectionToggleMenuItem.cs index 5ad06a72c0..e0e278e9a3 100644 --- a/osu.Game/Collections/CollectionToggleMenuItem.cs +++ b/osu.Game/Collections/CollectionToggleMenuItem.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading.Tasks; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.UserInterface; @@ -10,7 +11,7 @@ namespace osu.Game.Collections public class CollectionToggleMenuItem : ToggleMenuItem { public CollectionToggleMenuItem(Live collection, IBeatmapInfo beatmap) - : base(collection.PerformRead(c => c.Name), MenuItemType.Standard, state => + : base(collection.PerformRead(c => c.Name), MenuItemType.Standard, state => Task.Run(() => { collection.PerformWrite(c => { @@ -19,7 +20,7 @@ namespace osu.Game.Collections else c.BeatmapMD5Hashes.Remove(beatmap.MD5Hash); }); - }) + })) { State.Value = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.MD5Hash)); } diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 85af1d383d..c494b830d1 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -96,7 +96,6 @@ namespace osu.Game.Collections lastCreated = collections[changes.InsertedIndices[0]].ID; foreach (int i in changes.NewModifiedIndices) - { var updatedItem = collections[i]; diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index e86254329f..3031112333 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -15,7 +15,9 @@ using osu.Framework.Localisation; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -27,7 +29,7 @@ namespace osu.Game.Collections /// public partial class DrawableCollectionListItem : OsuRearrangeableListItem>, IFilterable { - private const float item_height = 35; + private const float item_height = 45; private const float button_width = item_height * 0.75f; protected TextBox TextBox => content.TextBox; @@ -92,13 +94,11 @@ namespace osu.Game.Collections Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Children = new Drawable[] { - TextBox = new ItemTextBox + TextBox = new ItemTextBox(collection) { - RelativeSizeAxes = Axes.Both, - Size = Vector2.One, - CornerRadius = item_height / 2, + RelativeSizeAxes = Axes.X, + Height = item_height, CommitOnFocusLost = true, - PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection" }, } }, @@ -125,11 +125,57 @@ namespace osu.Game.Collections { protected override float LeftRightPadding => item_height / 2; + private const float count_text_size = 12; + + private readonly Live collection; + + private OsuSpriteText countText = null!; + + public ItemTextBox(Live collection) + { + this.collection = collection; + + CornerRadius = item_height / 2; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { BackgroundUnfocused = colours.GreySeaFoamDarker.Darken(0.5f); BackgroundFocused = colours.GreySeaFoam; + + if (collection.IsManaged) + { + TextContainer.Height *= (Height - count_text_size) / Height; + TextContainer.Margin = new MarginPadding { Bottom = count_text_size }; + + TextContainer.Add(countText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Depth = float.MinValue, + Font = OsuFont.Default.With(size: count_text_size, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Top = 2, Left = 2 }, + Colour = colours.Yellow + }); + + // interestingly, it is not required to subscribe to change notifications on this collection at all for this to work correctly. + // the reasoning for this is that `DrawableCollectionList` already takes out a subscription on the set of all `BeatmapCollection`s - + // but that subscription does not only cover *changes to the set of collections* (i.e. addition/removal/rearrangement of collections), + // but also covers *changes to the properties of collections*, which `BeatmapMD5Hashes` is one. + // when a collection item changes due to `BeatmapMD5Hashes` changing, the list item is deleted and re-inserted, thus guaranteeing this to work correctly. + int count = collection.PerformRead(c => c.BeatmapMD5Hashes.Count); + + countText.Text = count == 1 + // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 + // but also in this case we want support for formatting a number within a string). + ? $"{count:#,0} item" + : $"{count:#,0} items"; + } + else + { + PlaceholderText = CollectionsStrings.CreateNew; + } } } @@ -210,7 +256,7 @@ namespace osu.Game.Collections private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c)); } - public IEnumerable FilterTerms => [(LocalisableString)Model.Value.Name]; + public IEnumerable FilterTerms => Model.PerformRead(m => m.IsValid ? new[] { (LocalisableString)m.Name } : []); private bool matchingFilter = true; diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index a738ae66cb..79166840f9 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -79,7 +80,7 @@ namespace osu.Game.Collections { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Manage collections", + Text = CollectionsStrings.ManageCollectionsTitle, Font = OsuFont.GetFont(size: 30), Padding = new MarginPadding { Vertical = 10 }, }, diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 4f62db8cf7..cdccf7eb61 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; +using osu.Framework; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; @@ -21,6 +21,7 @@ using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; using osu.Game.Users; @@ -40,14 +41,15 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Ruleset, string.Empty); SetDefault(OsuSetting.Skin, SkinInfo.ARGON_SKIN.ToString()); - SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Local); + SetDefault(OsuSetting.BeatmapDetailTab, BeatmapDetailTab.Local); + SetDefault(OsuSetting.BeatmapLeaderboardSortMode, LeaderboardSortMode.Score); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); SetDefault(OsuSetting.ShowConvertedBeatmaps, true); SetDefault(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); SetDefault(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); - SetDefault(OsuSetting.SongSelectGroupingMode, GroupMode.All); + SetDefault(OsuSetting.SongSelectGroupMode, GroupMode.None); SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title); SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); @@ -57,12 +59,13 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f, 0.01f); SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal); + SetDefault(OsuSetting.BeatmapListingFeaturedArtistFilter, true); SetDefault(OsuSetting.ProfileCoverExpanded, true); SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full); - SetDefault(OsuSetting.SongSelectBackgroundBlur, true); + SetDefault(OsuSetting.SongSelectBackgroundBlur, false); // Online settings SetDefault(OsuSetting.Username, string.Empty); @@ -94,6 +97,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.NotifyOnUsernameMentioned, true); SetDefault(OsuSetting.NotifyOnPrivateMessage, true); + SetDefault(OsuSetting.NotifyOnFriendPresenceChange, true); // Audio SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); @@ -104,6 +108,8 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); + SetDefault(OsuSetting.AutomaticallyAdjustBeatmapOffset, false); + // Input SetDefault(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); SetDefault(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); @@ -162,12 +168,11 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Version, string.Empty); SetDefault(OsuSetting.ShowFirstRunSetup, true); + SetDefault(OsuSetting.ShowMobileDisclaimer, RuntimeInfo.IsMobile); SetDefault(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg); SetDefault(OsuSetting.ScreenshotCaptureMenuCursor, false); - SetDefault(OsuSetting.SongSelectRightMouseScroll, false); - SetDefault(OsuSetting.Scaling, ScalingMode.Off); SetDefault(OsuSetting.SafeAreaConsiderations, true); SetDefault(OsuSetting.ScalingBackgroundDim, 0.9f, 0.5f, 1f, 0.01f); @@ -178,7 +183,10 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f, 0.01f); SetDefault(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f, 0.01f); - SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); + if (RuntimeInfo.IsMobile) + SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.1f, 0.01f); + else + SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); SetDefault(OsuSetting.UIHoldActivationDelay, 200.0, 0.0, 500.0, 50.0); @@ -202,11 +210,12 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.HideCountryFlags, false); SetDefault(OsuSetting.MultiplayerRoomFilter, RoomPermissionsFilter.All); + SetDefault(OsuSetting.MultiplayerShowInProgressFilter, true); SetDefault(OsuSetting.LastProcessedMetadataId, -1); SetDefault(OsuSetting.ComboColourNormalisationAmount, 0.2f, 0f, 1f, 0.01f); - SetDefault(OsuSetting.UserOnlineStatus, null); + SetDefault(OsuSetting.UserOnlineStatus, UserStatus.Online); SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true); SetDefault(OsuSetting.EditorTimelineShowBreaks, true); @@ -216,6 +225,15 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, false); SetDefault(OsuSetting.AlwaysRequireHoldingForPause, false); + SetDefault(OsuSetting.EditorShowStoryboard, true); + + SetDefault(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, true); + SetDefault(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, true); + + SetDefault(OsuSetting.WasSupporter, false); + + // intentionally uses `DateTime?` and not `DateTimeOffset?` because the latter fails due to `DateTimeOffset` not implementing `IConvertible` + SetDefault(OsuSetting.LastOnlineTagsPopulation, (DateTime?)null); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -231,7 +249,7 @@ namespace osu.Game.Configuration public void Migrate() { - // arrives as 2020.123.0 + // arrives as 2020.123.0-lazer string rawVersion = Get(OsuSetting.Version); if (rawVersion.Length < 6) @@ -244,19 +262,18 @@ namespace osu.Game.Configuration if (!int.TryParse(pieces[0], out int year)) return; if (!int.TryParse(pieces[1], out int monthDay)) return; - // ReSharper disable once UnusedVariable - int combined = (year * 10000) + monthDay; + int combined = year * 10000 + monthDay; - // migrations can be added here using a condition like: - // if (combined < 20220103) { performMigration() } + if (combined < 20250214) + { + // UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before. + if (RuntimeInfo.IsMobile) + GetBindable(OsuSetting.UIScale).SetDefault(); + } } public override TrackedSettings CreateTrackedSettings() { - // these need to be assigned in normal game startup scenarios. - Debug.Assert(LookupKeyBindings != null); - Debug.Assert(LookupSkinName != null); - return new TrackedSettings { new TrackedSetting(OsuSetting.ShowFpsDisplay, state => new SettingDescription( @@ -320,8 +337,7 @@ namespace osu.Game.Configuration } public Func LookupSkinName { private get; set; } = _ => @"unknown"; - - public Func LookupKeyBindings { get; set; } = _ => @"unknown"; + public Func LookupKeyBindings { private get; set; } = _ => @"unknown"; IBindable IGameplaySettings.ComboColourNormalisationAmount => GetOriginalBindable(OsuSetting.ComboColourNormalisationAmount); IBindable IGameplaySettings.PositionalHitsoundsLevel => GetOriginalBindable(OsuSetting.PositionalHitsoundsLevel); @@ -373,6 +389,7 @@ namespace osu.Game.Configuration MenuParallax, Prefer24HourTime, BeatmapDetailTab, + BeatmapLeaderboardSortMode, BeatmapDetailModsFilter, Username, ReleaseStream, @@ -380,7 +397,7 @@ namespace osu.Game.Configuration SaveUsername, DisplayStarsMinimum, DisplayStarsMaximum, - SongSelectGroupingMode, + SongSelectGroupMode, SongSelectSortingMode, RandomSelectAlgorithm, ModSelectHotkeyStyle, @@ -395,7 +412,6 @@ namespace osu.Game.Configuration Skin, ScreenshotFormat, ScreenshotCaptureMenuCursor, - SongSelectRightMouseScroll, BeatmapSkins, BeatmapColours, BeatmapHitsounds, @@ -413,6 +429,7 @@ namespace osu.Game.Configuration IntroSequence, NotifyOnUsernameMentioned, NotifyOnPrivateMessage, + NotifyOnFriendPresenceChange, UIHoldActivationDelay, HitLighting, StarFountains, @@ -436,7 +453,12 @@ namespace osu.Game.Configuration EditorShowSpeedChanges, TouchDisableGameplayTaps, ModSelectTextSearchStartsActive, + + /// + /// The status for the current user to broadcast to other players. + /// UserOnlineStatus, + MultiplayerRoomFilter, HideCountryFlags, EditorTimelineShowTimingChanges, @@ -447,6 +469,22 @@ namespace osu.Game.Configuration EditorRotationOrigin, EditorTimelineShowBreaks, EditorAdjustExistingObjectsOnTimingChanges, - AlwaysRequireHoldingForPause + AlwaysRequireHoldingForPause, + MultiplayerShowInProgressFilter, + BeatmapListingFeaturedArtistFilter, + ShowMobileDisclaimer, + EditorShowStoryboard, + EditorSubmissionNotifyOnDiscussionReplies, + EditorSubmissionLoadInBrowserAfterSubmission, + + /// + /// Cached state of whether local user is a supporter. + /// Used to allow early checks (ie for startup samples) to be in the correct state, even if the API authentication process has not completed. + /// + WasSupporter, + + LastOnlineTagsPopulation, + + AutomaticallyAdjustBeatmapOffset, } } diff --git a/osu.Game/Configuration/ReleaseStream.cs b/osu.Game/Configuration/ReleaseStream.cs index ed0bee1dd8..d4f382099c 100644 --- a/osu.Game/Configuration/ReleaseStream.cs +++ b/osu.Game/Configuration/ReleaseStream.cs @@ -1,13 +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.ComponentModel; + namespace osu.Game.Configuration { public enum ReleaseStream { Lazer, - //Stable40, - //Beta40, - //Stable + + [Description("Tachyon (Unstable)")] + Tachyon } } diff --git a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs index cd21eb6fa8..49f7657f91 100644 --- a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs +++ b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs @@ -40,10 +40,10 @@ namespace osu.Game.Configuration if (newScore.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs)) return; - if (newScore.HitEvents.Count < 10) + if (newScore.HitEvents.Count < 50) return; - if (newScore.HitEvents.CalculateAverageHitError() is not double averageError) + if (newScore.HitEvents.CalculateMedianHitError() is not double medianError) return; // keep a sane maximum number of entries. @@ -51,7 +51,7 @@ namespace osu.Game.Configuration averageHitErrorHistory.RemoveAt(0); double globalOffset = configManager.Get(OsuSetting.AudioOffset); - averageHitErrorHistory.Add(new DataPoint(averageError, globalOffset)); + averageHitErrorHistory.Add(new DataPoint(medianError, globalOffset)); } public void ClearHistory() => averageHitErrorHistory.Clear(); diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 18631f5d00..59e107a23e 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -10,7 +8,9 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Scoring; -using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osu.Game.Users; namespace osu.Game.Configuration { @@ -27,9 +27,13 @@ namespace osu.Game.Configuration SetDefault(Static.FeaturedArtistDisclaimerShownOnce, false); SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); SetDefault(Static.LastModSelectPanelSamplePlaybackTime, (double?)null); - SetDefault(Static.SeasonalBackgrounds, null); + SetDefault(Static.LastRankChangeSamplePlaybackTime, (double?)null); + SetDefault(Static.SeasonalBackgrounds, null); SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile); - SetDefault(Static.LastLocalUserScore, null); + SetDefault(Static.LastLocalUserScore, null); + SetDefault(Static.LastAppliedOffsetScore, null); + SetDefault(Static.UserOnlineActivity, null); + SetDefault(Static.AllBeatmapTags, null); } /// @@ -71,6 +75,12 @@ namespace osu.Game.Configuration /// LastModSelectPanelSamplePlaybackTime, + /// + /// The last playback time in milliseconds of a rank up/down sample (in and ). + /// Used to debounce rank change sounds game-wide to avoid potential volume saturation from multiple simultaneous playback. + /// + LastRankChangeSamplePlaybackTime, + /// /// Whether the last positional input received was a touch input. /// Used in touchscreen detection scenarios (). @@ -78,15 +88,26 @@ namespace osu.Game.Configuration TouchInputActive, /// - /// Contains the local user's last score (can be completed or aborted) after exiting . - /// Will be cleared to null when leaving . + /// Stores the local user's last score (can be completed or aborted). /// LastLocalUserScore, + /// + /// Stores the local user's last score which was used to apply an offset. + /// + LastAppliedOffsetScore, + /// /// Whether the intro animation for the daily challenge screen has been played once. /// This is reset when a new challenge is up. /// DailyChallengeIntroPlayed, + + /// + /// The activity for the current user to broadcast to other players. + /// + UserOnlineActivity, + + AllBeatmapTags, } } diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index 1512b6be93..f8d1b9ae51 100644 --- a/osu.Game/Database/BackgroundDataStoreProcessor.cs +++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs @@ -10,10 +10,12 @@ using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Overlays; @@ -23,6 +25,7 @@ using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Screens.Play; +using Realms; namespace osu.Game.Database { @@ -66,24 +69,33 @@ namespace osu.Game.Database [Resolved] private Storage storage { get; set; } = null!; + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private LocalCachedBeatmapMetadataSource localMetadataSource = null!; + protected virtual int TimeToSleepDuringGameplay => 30000; protected override void LoadComplete() { base.LoadComplete(); + localMetadataSource = new LocalCachedBeatmapMetadataSource(storage); + ProcessingTask = Task.Factory.StartNew(() => { Logger.Log("Beginning background data store processing.."); - checkForOutdatedStarRatings(); - processBeatmapSetsWithMissingMetrics(); + clearOutdatedStarRatings(); + populateMissingStarRatings(); + processOnlineBeatmapSetsWithNoUpdate(); // Note that the previous method will also update these on a fresh run. processBeatmapsWithMissingObjectCounts(); processScoresWithMissingStatistics(); convertLegacyTotalScoreToStandardised(); upgradeScoreRanks(); backpopulateMissingSubmissionAndRankDates(); + backpopulateUserTags(); }, TaskCreationOptions.LongRunning).ContinueWith(t => { if (t.Exception?.InnerException is ObjectDisposedException) @@ -100,7 +112,7 @@ namespace osu.Game.Database /// Check whether the databased difficulty calculation version matches the latest ruleset provided version. /// If it doesn't, clear out any existing difficulties so they can be incrementally recalculated. /// - private void checkForOutdatedStarRatings() + private void clearOutdatedStarRatings() { foreach (var ruleset in rulesetStore.AvailableRulesets) { @@ -132,7 +144,86 @@ namespace osu.Game.Database } } - private void processBeatmapSetsWithMissingMetrics() + /// + /// This is split out from as a separate process to prevent high server-side load + /// from the firing online requests as part of the update. + /// Star rating recalculations can be ran strictly locally. + /// + private void populateMissingStarRatings() + { + HashSet beatmapIds = new HashSet(); + + Logger.Log("Querying for beatmaps with missing star ratings..."); + + realmAccess.Run(r => + { + foreach (var b in r.All().Where(b => b.StarRating < 0 && b.BeatmapSet != null)) + beatmapIds.Add(b.ID); + }); + + if (beatmapIds.Count == 0) + return; + + Logger.Log($"Found {beatmapIds.Count} beatmaps which require star rating reprocessing."); + + var notification = showProgressNotification(beatmapIds.Count, "Reprocessing star rating for beatmaps", "beatmaps' star ratings have been updated"); + + int processedCount = 0; + int failedCount = 0; + + Dictionary rulesetCache = new Dictionary(); + + Ruleset getRuleset(RulesetInfo rulesetInfo) + { + if (!rulesetCache.TryGetValue(rulesetInfo.ShortName, out var ruleset)) + ruleset = rulesetCache[rulesetInfo.ShortName] = rulesetInfo.CreateInstance(); + + return ruleset; + } + + foreach (Guid id in beatmapIds) + { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, beatmapIds.Count); + + sleepIfRequired(); + + var beatmap = realmAccess.Run(r => r.Find(id)?.Detach()); + + if (beatmap == null) + return; + + try + { + var working = beatmapManager.GetWorkingBeatmap(beatmap); + var ruleset = getRuleset(working.BeatmapInfo.Ruleset); + + Debug.Assert(ruleset != null); + + var calculator = ruleset.CreateDifficultyCalculator(working); + + double starRating = calculator.Calculate().StarRating; + realmAccess.Write(r => + { + if (r.Find(id) is BeatmapInfo liveBeatmapInfo) + liveBeatmapInfo.StarRating = starRating; + }); + ((IWorkingBeatmapCache)beatmapManager).Invalidate(beatmap); + ++processedCount; + } + catch (Exception e) + { + Logger.Log($"Background processing failed on {beatmap}: {e}"); + ++failedCount; + } + } + + completeNotification(notification, processedCount, beatmapIds.Count, failedCount); + } + + private void processOnlineBeatmapSetsWithNoUpdate() { HashSet beatmapSetIds = new HashSet(); @@ -148,12 +239,7 @@ namespace osu.Game.Database // of other possible ways), but for now avoid queueing if the user isn't logged in at startup. if (api.IsLoggedIn) { - foreach (var b in r.All().Where(b => (b.StarRating < 0 || (b.OnlineID > 0 && b.LastOnlineUpdate == null)) && b.BeatmapSet != null)) - beatmapSetIds.Add(b.BeatmapSet!.ID); - } - else - { - foreach (var b in r.All().Where(b => b.StarRating < 0 && b.BeatmapSet != null)) + foreach (var b in r.All().Where(b => b.OnlineID > 0 && b.LastOnlineUpdate == null && b.BeatmapSet != null)) beatmapSetIds.Add(b.BeatmapSet!.ID); } }); @@ -161,10 +247,9 @@ namespace osu.Game.Database if (beatmapSetIds.Count == 0) return; - Logger.Log($"Found {beatmapSetIds.Count} beatmap sets which require reprocessing."); + Logger.Log($"Found {beatmapSetIds.Count} beatmap sets which require online updates."); - // Technically this is doing more than just star ratings, but easier for the end user to understand. - var notification = showProgressNotification(beatmapSetIds.Count, "Reprocessing star rating for beatmaps", "beatmaps' star ratings have been updated"); + var notification = showProgressNotification(beatmapSetIds.Count, "Updating online data for beatmaps", "beatmaps' online data have been updated"); int processedCount = 0; int failedCount = 0; @@ -407,7 +492,7 @@ namespace osu.Game.Database if (scoreIds.Count == 0) return; - var notification = showProgressNotification(scoreIds.Count, "Adjusting ranks of scores", "scores now have more correct ranks"); + var notification = showProgressNotification(scoreIds.Count, "Adjusting ranks of scores", "scores now have more correct ranks."); int processedCount = 0; int failedCount = 0; @@ -451,8 +536,6 @@ namespace osu.Game.Database private void backpopulateMissingSubmissionAndRankDates() { - var localMetadataSource = new LocalCachedBeatmapMetadataSource(storage); - if (!localMetadataSource.Available) { Logger.Log("Cannot backpopulate missing submission/rank dates because the local metadata cache is missing."); @@ -461,7 +544,7 @@ namespace osu.Game.Database try { - if (localMetadataSource.GetCacheVersion() < 2) + if (!localMetadataSource.IsAtLeastVersion(2)) { Logger.Log("Cannot backpopulate missing submission/rank dates because the local metadata cache is too old."); return; @@ -475,9 +558,15 @@ namespace osu.Game.Database Logger.Log("Querying for beatmap sets that contain missing submission/rank date..."); + // find all ranked beatmap sets with missing date ranked or date submitted that have at least one difficulty ranked as well. + // the reason for checking ranked status of the difficulties is that they can be locally modified or unknown too, and for those the lookup is likely to fail. + // this is because metadata lookups are primarily based on file hash, so they will fail to match if the beatmap does not match the online version + // (which is likely to be the case if the beatmap is locally modified or unknown). + // that said, one difficulty in ranked state is enough for the backpopulation to work. HashSet beatmapSetIds = realmAccess.Run(r => new HashSet( r.All() - .Where(b => b.StatusInt > 0 && (b.DateRanked == null || b.DateSubmitted == null)) + .Filter($@"{nameof(BeatmapSetInfo.StatusInt)} > 0 && ({nameof(BeatmapSetInfo.DateRanked)} == null || {nameof(BeatmapSetInfo.DateSubmitted)} == null) " + + $@"&& ANY {nameof(BeatmapSetInfo.Beatmaps)}.{nameof(BeatmapInfo.StatusInt)} > 0") .AsEnumerable() .Select(b => b.ID))); @@ -508,11 +597,7 @@ namespace osu.Game.Database { BeatmapSetInfo beatmapSet = r.Find(id)!; - // we want any ranked representative of the set. - // the reason for checking ranked status of the difficulty is that it can be locally modified, - // at which point the lookup will fail - but there might still be another unmodified difficulty on which it will work. - if (beatmapSet.Beatmaps.FirstOrDefault(b => b.Status >= BeatmapOnlineStatus.Ranked) is not BeatmapInfo beatmap) - return false; + var beatmap = beatmapSet.Beatmaps.First(b => b.Status >= BeatmapOnlineStatus.Ranked); bool lookupSucceeded = localMetadataSource.TryLookup(beatmap, out var result); @@ -547,6 +632,110 @@ namespace osu.Game.Database completeNotification(notification, processedCount, beatmapSetIds.Count, failedCount); } + private void backpopulateUserTags() + { + if (!localMetadataSource.Available || !localMetadataSource.IsAtLeastVersion(3)) + { + Logger.Log(@"Local metadata cache has too low version to backpopulate user tags, attempting refetch..."); + localMetadataSource.FetchCache().WaitSafely(); + + if (!localMetadataSource.Available || !localMetadataSource.IsAtLeastVersion(3)) + { + Logger.Log(@"Local metadata cache refetch failed. Aborting user tags backpopulation."); + return; + } + } + + var lastPopulation = config.Get(OsuSetting.LastOnlineTagsPopulation); + // dropping time data here completely is intentional, because storing the date to config is a lossy operation + // (truncates some ticks off of the date when it's being converted to string and back). + // therefore, if precision isn't explicitly constrained, the condition below would always fail just because the date stored to config + // is less accurate than the cache file's fetch date which is stored with higher precision in the filesystem metadata. + var metadataSourceFetchDate = localMetadataSource.GetCacheFetchDate()?.Date; + + if (metadataSourceFetchDate <= lastPopulation) + { + Logger.Log($@"Skipping user tag population because the local metadata source hasn't been updated since the last time user tags were checked ({lastPopulation.Value:d})"); + return; + } + + Logger.Log(@"Querying for beatmaps that do not have user tags"); + + // it is not an abnormal situation for a map not to have user tags. + // while this is constrained to run every month or so (every time a new online.db cache is retrieved), there's some chance that this will still run much too often and be annoying to users. + // if that turns out to be the case we may need a better way to debounce this (or just delete the backpopulation logic after some time has passed?) + HashSet beatmapIds = realmAccess.Run(r => new HashSet( + r.All() + .Filter($"{nameof(BeatmapInfo.Metadata)}.{nameof(BeatmapMetadata.UserTags)}.@count == 0 AND {nameof(BeatmapInfo.StatusInt)} IN {{ 1,2,4 }}") + .AsEnumerable() + .Select(b => b.ID))); + + if (beatmapIds.Count == 0) + return; + + Logger.Log($@"Found {beatmapIds.Count} beatmaps with missing user tags."); + + var notification = showProgressNotification(beatmapIds.Count, @"Populating missing user tags", @"beatmaps have had their tags updated."); + + int processedCount = 0; + int failedCount = 0; + + foreach (var id in beatmapIds) + { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, beatmapIds.Count); + + sleepIfRequired(); + + try + { + // Can't use async overload because we're not on the update thread. + // ReSharper disable once MethodHasAsyncOverload + realmAccess.Write(r => + { + BeatmapInfo beatmap = r.Find(id)!; + + bool lookupSucceeded = localMetadataSource.TryLookup(beatmap, out var result); + + if (lookupSucceeded) + { + Debug.Assert(result != null); + + var userTags = result.UserTags.ToHashSet(); + + if (!userTags.SetEquals(beatmap.Metadata.UserTags)) + { + beatmap.Metadata.UserTags.Clear(); + beatmap.Metadata.UserTags.AddRange(userTags); + return true; + } + + return false; + } + + Logger.Log(@$"Could not find {beatmap.GetDisplayString()} in local cache while backpopulating missing user tags"); + return false; + }); + + ++processedCount; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception e) + { + Logger.Log(@$"Failed to update user tags for beatmap {id}: {e}"); + ++failedCount; + } + } + + completeNotification(notification, processedCount, beatmapIds.Count, failedCount); + config.SetValue(OsuSetting.LastOnlineTagsPopulation, metadataSourceFetchDate); + } + private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount) { if (notification == null) diff --git a/osu.Game/Database/BeatmapStore.cs b/osu.Game/Database/BeatmapStore.cs new file mode 100644 index 0000000000..9853e4b9cf --- /dev/null +++ b/osu.Game/Database/BeatmapStore.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; + +namespace osu.Game.Database +{ + /// + /// A store which contains a thread-safe representation of beatmaps available game-wide. + /// This exposes changes to available beatmaps, such as post-import or deletion. + /// + /// + /// The main goal of classes which implement this interface should be to provide change + /// tracking and thread safety in a performant way, rather than having to worry about such + /// concerns at the point of usage. + /// + public abstract partial class BeatmapStore : Component + { + /// + /// Get all available beatmaps. + /// + /// A cancellation token which allows early abort from the operation. + /// A bindable list of all available beatmap sets. + /// + /// This operation may block during the initial load process. + /// + /// It is generally expected that once a beatmap store is in a good state, the overhead of this call + /// should be negligible. + /// + public abstract IBindableList GetBeatmapSets(CancellationToken? cancellationToken); + } +} diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index ce1563f2df..2851fe7f70 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -41,7 +41,7 @@ namespace osu.Game.Database /// When editing is completed, call Finish() on the returned operation class to begin the import-and-update process. /// /// The model to mount. - public Task> BeginExternalEditing(TModel model); + Task> BeginExternalEditing(TModel model); /// /// A user displayable name for the model type associated with this manager. @@ -51,6 +51,6 @@ namespace osu.Game.Database /// /// Fired when the user requests to view the resulting import. /// - public Action>>? PresentImport { set; } + Action>>? PresentImport { set; } } } diff --git a/osu.Game/Database/IPostNotifications.cs b/osu.Game/Database/IPostNotifications.cs index 205350b80c..d6a267b04f 100644 --- a/osu.Game/Database/IPostNotifications.cs +++ b/osu.Game/Database/IPostNotifications.cs @@ -11,6 +11,6 @@ namespace osu.Game.Database /// /// And action which will be fired when a notification should be presented to the user. /// - public Action PostNotification { set; } + Action PostNotification { set; } } } diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index eb48425588..8d90c9adb4 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -42,7 +42,10 @@ namespace osu.Game.Database return null; using var contentStreamReader = new LineBufferedReader(contentStream); - var beatmapContent = new LegacyBeatmapDecoder().Decode(contentStreamReader); + + // FIRST_LAZER_VERSION is specified here to avoid flooring object coordinates on decode via `(int)` casts. + // we will be making integers out of them lower down, but in a slightly different manner (rounding rather than truncating) + var beatmapContent = new LegacyBeatmapDecoder(LegacyBeatmapEncoder.FIRST_LAZER_VERSION).Decode(contentStreamReader); var workingBeatmap = new FlatWorkingBeatmap(beatmapContent); var playableBeatmap = workingBeatmap.GetPlayableBeatmap(beatmapInfo.Ruleset); @@ -58,6 +61,20 @@ namespace osu.Game.Database Configuration = new LegacySkinDecoder().Decode(skinStreamReader) }; + MutateBeatmap(model, playableBeatmap); + + // Encode to legacy format + var stream = new MemoryStream(); + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw); + + stream.Seek(0, SeekOrigin.Begin); + + return stream; + } + + protected virtual void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap) + { // Convert beatmap elements to be compatible with legacy format // So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves @@ -93,6 +110,12 @@ namespace osu.Game.Database hitObject.StartTime = Math.Floor(hitObject.StartTime); + if (hitObject is IHasXPosition hasXPosition) + hasXPosition.X = MathF.Round(hasXPosition.X); + + if (hitObject is IHasYPosition hasYPosition) + hasYPosition.Y = MathF.Round(hasYPosition.Y); + if (hitObject is not IHasPath hasPath) continue; // stable's hit object parsing expects the entire slider to use only one type of curve, @@ -109,30 +132,46 @@ namespace osu.Game.Database hasPath.Path.ControlPoints[^1].Type = null; if (BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1 - && hasPath.Path.ControlPoints[0].Type!.Value.Degree == null) continue; - - var newControlPoints = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints); - - // Truncate control points to integer positions - foreach (var pathControlPoint in newControlPoints) + && hasPath.Path.ControlPoints[0].Type!.Value.Degree == null) { - pathControlPoint.Position = new Vector2( - (float)Math.Floor(pathControlPoint.Position.X), - (float)Math.Floor(pathControlPoint.Position.Y)); + // Round every control point to integer positions before skipping to the next hit object + for (int i = 0; i < hasPath.Path.ControlPoints.Count; i++) + { + var position = new Vector2( + MathF.Round(hasPath.Path.ControlPoints[i].Position.X), + MathF.Round(hasPath.Path.ControlPoints[i].Position.Y)); + + hasPath.Path.ControlPoints[i].Position = position; + } + + continue; } + var convertedToBezier = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints); + hasPath.Path.ControlPoints.Clear(); - hasPath.Path.ControlPoints.AddRange(newControlPoints); + + for (int i = 0; i < convertedToBezier.Count; i++) + { + var convertedPoint = convertedToBezier[i]; + + // Round control points to integer positions + var position = new Vector2( + MathF.Round(convertedPoint.Position.X), + MathF.Round(convertedPoint.Position.Y)); + + // stable only supports a single curve type specification per slider. + // we exploit the fact that the converted-to-Bézier path only has Bézier segments, + // and thus we specify the Bézier curve type once ever at the start of the slider. + hasPath.Path.ControlPoints.Add(new PathControlPoint(position, i == 0 ? PathType.BEZIER : null)); + + // however, the Bézier path as output by the converter has multiple segments. + // `LegacyBeatmapEncoder` will attempt to encode this by emitting per-control-point curve type specs which don't do anything for stable. + // instead, stable expects control points that start a segment to be present in the path twice in succession. + if (convertedPoint.Type == PathType.BEZIER && i > 0) + hasPath.Path.ControlPoints.Add(new PathControlPoint(position)); + } } - - // Encode to legacy format - var stream = new MemoryStream(); - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw); - - stream.Seek(0, SeekOrigin.Begin); - - return stream; } protected override string FileExtension => @".osz"; diff --git a/osu.Game/Database/LegacyExporter.cs b/osu.Game/Database/LegacyExporter.cs index f9164e34cd..80393c27f7 100644 --- a/osu.Game/Database/LegacyExporter.cs +++ b/osu.Game/Database/LegacyExporter.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Platform; using osu.Game.Extensions; +using osu.Game.IO; using osu.Game.Overlays.Notifications; using osu.Game.Utils; using Realms; @@ -46,7 +47,7 @@ namespace osu.Game.Database protected LegacyExporter(Storage storage) { - exportStorage = storage.GetStorageForDirectory(@"exports"); + exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); UserFileStorage = storage.GetStorageForDirectory(@"files"); } diff --git a/osu.Game/Database/LegacySkinExporter.cs b/osu.Game/Database/LegacySkinExporter.cs index 14a3907916..98c4c5dbea 100644 --- a/osu.Game/Database/LegacySkinExporter.cs +++ b/osu.Game/Database/LegacySkinExporter.cs @@ -13,6 +13,8 @@ namespace osu.Game.Database { } + protected override bool UseFixedEncoding => false; + protected override string FileExtension => @".osk"; } } diff --git a/osu.Game/Database/MemoryCachingComponent.cs b/osu.Game/Database/MemoryCachingComponent.cs index e98475efae..a91c608279 100644 --- a/osu.Game/Database/MemoryCachingComponent.cs +++ b/osu.Game/Database/MemoryCachingComponent.cs @@ -35,8 +35,9 @@ namespace osu.Game.Database /// Retrieve the cached value for the given lookup. /// /// The lookup to retrieve. - /// An optional to cancel the operation. - protected async Task GetAsync(TLookup lookup, CancellationToken token = default) + /// An optional to cancel the operation. + /// In the case a cached lookup was not possible, a value in milliseconds of to wait until performing potentially intensive lookup. + protected async Task GetAsync(TLookup lookup, CancellationToken cancellationToken = default, int computationDelay = 0) { if (CheckExists(lookup, out TValue? existing)) { @@ -44,7 +45,10 @@ namespace osu.Game.Database return existing; } - var computed = await ComputeValueAsync(lookup, token).ConfigureAwait(false); + if (computationDelay > 0) + await Task.Delay(computationDelay, cancellationToken).ConfigureAwait(false); + + var computed = await ComputeValueAsync(lookup, cancellationToken).ConfigureAwait(false); statistics.Value.MissCount++; diff --git a/osu.Game/Database/MissingBeatmapNotification.cs b/osu.Game/Database/MissingBeatmapNotification.cs index 584b2675f3..fff2448f3f 100644 --- a/osu.Game/Database/MissingBeatmapNotification.cs +++ b/osu.Game/Database/MissingBeatmapNotification.cs @@ -28,7 +28,7 @@ namespace osu.Game.Database [Resolved] private RealmAccess realm { get; set; } = null!; - private readonly ArchiveReader scoreArchive; + private readonly ArchiveReader? scoreArchive; private readonly APIBeatmapSet beatmapSetInfo; private readonly string beatmapHash; @@ -38,7 +38,13 @@ namespace osu.Game.Database private IDisposable? realmSubscription; - public MissingBeatmapNotification(APIBeatmap beatmap, ArchiveReader scoreArchive, string beatmapHash) + /// + /// Creates a new notification about a missing beatmap that needs to be downloaded to proceed with an action. + /// + /// The online-retrieved beatmap to download. + /// The hash of the beatmap that is required to proceed. + /// Optional archive with a score. If not , a re-import of this archive will be attempted after the missing beatmap is downloaded. + public MissingBeatmapNotification(APIBeatmap beatmap, string beatmapHash, ArchiveReader? scoreArchive) { beatmapSetInfo = beatmap.BeatmapSet!; @@ -86,9 +92,13 @@ namespace osu.Game.Database if (sender.Any(s => s.Beatmaps.Any(b => b.MD5Hash == beatmapHash))) { - string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); - var importTask = new ImportTask(scoreArchive.GetStream(name), name); - scoreManager.Import(new[] { importTask }); + if (scoreArchive != null) + { + string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); + var importTask = new ImportTask(scoreArchive.GetStream(name), name); + scoreManager.Import(new[] { importTask }); + } + realmSubscription?.Dispose(); Close(false); } diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index dfeec259fe..8e89db4d06 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -131,8 +131,6 @@ namespace osu.Game.Database private partial class DownloadNotification : ProgressNotification { - public override bool IsImportant => false; - protected override Notification CreateCompletionNotification() => new SilencedProgressCompletionNotification { Activated = CompletionClickAction, @@ -141,7 +139,10 @@ namespace osu.Game.Database private partial class SilencedProgressCompletionNotification : ProgressCompletionNotification { - public override bool IsImportant => false; + public SilencedProgressCompletionNotification() + { + IsImportant = false; + } } } } diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs index 7a5fb5efbf..e96a8cc1b1 100644 --- a/osu.Game/Database/ModelManager.cs +++ b/osu.Game/Database/ModelManager.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Extensions; @@ -85,6 +86,7 @@ namespace osu.Game.Database /// public void AddFile(TModel item, Stream contents, string filename, Realm realm) { + filename = filename.ToStandardisedPath(); var existing = item.GetFile(filename); if (existing != null) diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index a520040ad1..fa54ed538a 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -11,7 +11,6 @@ using System.Linq.Expressions; using System.Reflection; using System.Threading; using System.Threading.Tasks; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Extensions; @@ -95,14 +94,23 @@ namespace osu.Game.Database /// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction /// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user. /// 44 2024-11-22 Removed several properties from BeatmapInfo which did not need to be persisted to realm. + /// 45 2024-12-23 Change beat snap divisor adjust defaults to be Ctrl+Scroll instead of Ctrl+Shift+Scroll, if not already changed by user. + /// 46 2024-12-26 Change beat snap divisor bindings to match stable directionality ¯\_(ツ)_/¯. + /// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions. + /// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues). + /// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID. + /// 50 2025-07-11 Add UserTags to BeatmapMetadata. + /// 51 2025-07-22 Add ScoreInfo.Pauses. /// - private const int schema_version = 44; + private const int schema_version = 51; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. /// private readonly SemaphoreSlim realmRetrievalLock = new SemaphoreSlim(1); + private readonly CountdownEvent pendingAsyncOperations = new CountdownEvent(0); + /// /// true when the current thread has already entered the . /// @@ -313,6 +321,17 @@ namespace osu.Game.Database attemptRecoverFromFile(newerVersionFilename); } + try + { + // Some platforms' realm implementation (including windows) don't update modified time on open. + // Let's do this explicitly as some users may depend on it roughly aligning to usage expectations. + string fullPath = storage.GetFullPath(Filename); + var fi = new FileInfo(fullPath); + if (fi.Exists) + fi.LastWriteTime = DateTime.Now; + } + catch { } + try { return getRealmInstance(); @@ -411,18 +430,7 @@ namespace osu.Game.Database /// Compact this realm. /// /// - public bool Compact() - { - try - { - return Realm.Compact(getConfiguration()); - } - // Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator). - catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException) - { - return true; - } - } + public bool Compact() => Realm.Compact(getConfiguration()); /// /// Run work on realm with a return value. @@ -461,6 +469,30 @@ namespace osu.Game.Database } } + /// + /// Run work on realm on a TPL thread, in a way that ensures that the realm isn't disposed before the work is done. + /// + public Task RunAsync(Func action, CancellationToken token = default) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + + // Required to ensure the read is tracked and accounted for before disposal. + // Can potentially be avoided if we have a need to do so in the future. + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($@"{nameof(RunAsync)} must be called from the update thread."); + + // CountdownEvent will fail if already at zero. + if (!pendingAsyncOperations.TryAddCount()) + pendingAsyncOperations.Reset(1); + + return Task.Run(() => + { + var result = Run(action); + pendingAsyncOperations.Signal(); + return result; + }, token); + } + /// /// Write changes to realm. /// @@ -501,8 +533,6 @@ namespace osu.Game.Database } } - private readonly CountdownEvent pendingAsyncWrites = new CountdownEvent(0); - /// /// Write changes to realm asynchronously, guaranteeing order of execution. /// @@ -517,8 +547,8 @@ namespace osu.Game.Database throw new InvalidOperationException(@$"{nameof(WriteAsync)} must be called from the update thread."); // CountdownEvent will fail if already at zero. - if (!pendingAsyncWrites.TryAddCount()) - pendingAsyncWrites.Reset(1); + if (!pendingAsyncOperations.TryAddCount()) + pendingAsyncOperations.Reset(1); // Regardless of calling Realm.GetInstance or Realm.GetInstanceAsync, there is a blocking overhead on retrieval. // Adding a forced Task.Run resolves this. @@ -533,7 +563,45 @@ namespace osu.Game.Database // ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]). await realm.WriteAsync(() => action(realm)).ConfigureAwait(false); - pendingAsyncWrites.Signal(); + pendingAsyncOperations.Signal(); + }); + + return writeTask; + } + + /// + /// Write changes to realm asynchronously, guaranteeing order of execution. + /// + /// The work to run. + public Task WriteAsync(Func action) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + + // Required to ensure the write is tracked and accounted for before disposal. + // Can potentially be avoided if we have a need to do so in the future. + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException(@$"{nameof(WriteAsync)} must be called from the update thread."); + + // CountdownEvent will fail if already at zero. + if (!pendingAsyncOperations.TryAddCount()) + pendingAsyncOperations.Reset(1); + + // Regardless of calling Realm.GetInstance or Realm.GetInstanceAsync, there is a blocking overhead on retrieval. + // Adding a forced Task.Run resolves this. + var writeTask = Task.Run(async () => + { + T result; + total_writes_async.Value++; + + // Not attempting to use Realm.GetInstanceAsync as there's seemingly no benefit to us (for now) and it adds complexity due to locking + // concerns in getRealmInstance(). On a quick check, it looks to be more suited to cases where realm is connecting to an online sync + // server, which we don't use. May want to report upstream or revisit in the future. + using (var realm = getRealmInstance()) + // ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]). + result = await realm.WriteAsync(() => action(realm)).ConfigureAwait(false); + + pendingAsyncOperations.Signal(); + return result; }); return writeTask; @@ -718,11 +786,6 @@ namespace osu.Game.Database return Realm.GetInstance(getConfiguration()); } - // Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator). - catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException) - { - return Realm.GetInstance(); - } finally { if (tookSemaphoreLock) @@ -1205,6 +1268,64 @@ namespace osu.Game.Database break; } + + case 45: + { + // Cycling beat snap divisors no longer requires holding shift (just control). + var keyBindings = migration.NewRealm.All(); + + var nextBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCycleNextBeatSnapDivisor); + if (nextBeatSnapBinding != null && nextBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.MouseWheelLeft })) + migration.NewRealm.Remove(nextBeatSnapBinding); + + var previousBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCyclePreviousBeatSnapDivisor); + if (previousBeatSnapBinding != null && previousBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.MouseWheelRight })) + migration.NewRealm.Remove(previousBeatSnapBinding); + + break; + } + + case 46: + { + // Stable direction didn't match. + var keyBindings = migration.NewRealm.All(); + + var nextBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCycleNextBeatSnapDivisor); + if (nextBeatSnapBinding != null && nextBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.MouseWheelDown })) + migration.NewRealm.Remove(nextBeatSnapBinding); + + var previousBeatSnapBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.EditorCyclePreviousBeatSnapDivisor); + if (previousBeatSnapBinding != null && previousBeatSnapBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Control, InputKey.MouseWheelUp })) + migration.NewRealm.Remove(previousBeatSnapBinding); + + break; + } + + case 47: + { + var keyBindings = migration.NewRealm.All(); + + var existingBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.AbsoluteScrollSongList); + if (existingBinding != null && existingBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.MouseRight })) + migration.NewRealm.Remove(existingBinding); + + break; + } + + case 48: + const int qualified = (int)BeatmapOnlineStatus.Qualified; + + var beatmaps = migration.NewRealm.All().Where(b => b.StatusInt == qualified); + + foreach (var beatmap in beatmaps) + beatmap.ResetOnlineInfo(resetOnlineId: false); + break; + + case 49: + foreach (var score in migration.NewRealm.All().Where(s => s.LegacyOnlineID == 0)) + score.LegacyOnlineID = -1; + + break; } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); @@ -1397,7 +1518,7 @@ namespace osu.Game.Database public void Dispose() { - if (!pendingAsyncWrites.Wait(10000)) + if (!pendingAsyncOperations.Wait(10000)) Logger.Log("Realm took too long waiting on pending async writes", level: LogLevel.Error); updateRealm?.Dispose(); diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index e538530b79..aefb628422 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -139,9 +139,14 @@ namespace osu.Game.Database notification.Progress = (float)current / tasks.Length; } } - catch (OperationCanceledException) + catch (OperationCanceledException cancelled) { - throw; + // We don't want to abort the full import process based off difficulty calculator's internal cancellation + // see https://github.com/ppy/osu/blob/91f3be5feaab0c73c17e1a8c270516aa9bee1e14/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs#L65. + if (cancelled.CancellationToken == notification.CancellationToken) + throw; + + Logger.Error(cancelled, $@"Timed out importing ({task})", LoggingTarget.Database); } catch (Exception e) { @@ -203,7 +208,18 @@ namespace osu.Game.Database foreach (var realmFile in model.Files) { string sourcePath = Files.Storage.GetFullPath(realmFile.File.GetStoragePath()); - string destinationPath = Path.Join(mountedPath, realmFile.Filename); + // there are edge cases where externalising an imported model to the filesystem could fail due to invalid filenames. + // one scenario where this happens goes something like this: + // - stable user exports an archive, which contains filenames that get mangled by stable's default zip encoding codepage (Shift-JIS) + // - said archive is imported to lazer, but the invalid filename is not actually an issue due to lazer file store structure + // (the file is stored under a filename correspondent to its SHA instead, and its real filename is only stored in realm) + // - however attempts to externally edit the model fail as the external edit attempts and fails to produce the file's "real" filename in the mounted path + // to prevent this bricking external edit, strip invalid characters on external edit. + // the presumption here is that whatever produced the mangled archive is primarily at fault here, and we're just trying to trudge on locally as best as possible. + // if there are further troubles related to similar issues, reevaluate moving this sort of check to the import side instead (sanitising filenames on import from archive). + string destinationPath = mountedPath; + foreach (string piece in realmFile.Filename.Split('/').Select(f => f.GetValidFilename())) + destinationPath = Path.Combine(destinationPath, piece); Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); diff --git a/osu.Game/Database/DetachedBeatmapStore.cs b/osu.Game/Database/RealmDetachedBeatmapStore.cs similarity index 56% rename from osu.Game/Database/DetachedBeatmapStore.cs rename to osu.Game/Database/RealmDetachedBeatmapStore.cs index 5b65f608b2..f9f84c52e5 100644 --- a/osu.Game/Database/DetachedBeatmapStore.cs +++ b/osu.Game/Database/RealmDetachedBeatmapStore.cs @@ -8,14 +8,13 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; using Realms; namespace osu.Game.Database { - public partial class DetachedBeatmapStore : Component + public partial class RealmDetachedBeatmapStore : BeatmapStore { private readonly ManualResetEventSlim loaded = new ManualResetEventSlim(); @@ -28,10 +27,11 @@ namespace osu.Game.Database [Resolved] private RealmAccess realm { get; set; } = null!; - public IBindableList GetDetachedBeatmaps(CancellationToken? cancellationToken) + public override IBindableList GetBeatmapSets(CancellationToken? cancellationToken) { loaded.Wait(cancellationToken ?? CancellationToken.None); - return detachedBeatmapSets.GetBoundCopy(); + lock (detachedBeatmapSets) + return detachedBeatmapSets.GetBoundCopy(); } [BackgroundDependencyLoader] @@ -66,8 +66,11 @@ namespace osu.Game.Database { var detached = frozenSets.Detach(); - detachedBeatmapSets.Clear(); - detachedBeatmapSets.AddRange(detached); + lock (detachedBeatmapSets) + { + detachedBeatmapSets.Clear(); + detachedBeatmapSets.AddRange(detached); + } }); } finally @@ -79,6 +82,34 @@ namespace osu.Game.Database return; } + if (changes.InsertedIndices.Length == 1 && changes.DeletedIndices.Length == 1) + { + lock (detachedBeatmapSets) + { + var deletedSet = detachedBeatmapSets[changes.DeletedIndices[0]]; + var insertedSet = sender[changes.InsertedIndices[0]]; + + // this handles beatmap updates using a heuristic that a beatmap update will preserve the online ID. + // it relies on the fact that updates are performed by removing the old set and adding a new one, in a single transaction. + // instead of removing the old set and adding a new one to the collection too, which would trigger consumers' logic related to set removals, + // move the deleted set to the index occupied by the new one and then replace it in-place. + // due to this, the operation can be presented to consumer in a manner that permits them to actually handle this as a replace operation + // and not trigger any set removal logic that may result in selections changing or similar undesirable side effects. + if (deletedSet.OnlineID == insertedSet.OnlineID) + { + pendingOperations.Enqueue(new OperationArgs + { + Type = OperationType.MoveAndReplace, + BeatmapSet = insertedSet.Detach(), + Index = changes.DeletedIndices[0], + NewIndex = changes.InsertedIndices[0], + }); + + return; + } + } + } + foreach (int i in changes.DeletedIndices.OrderDescending()) { pendingOperations.Enqueue(new OperationArgs @@ -117,22 +148,33 @@ namespace osu.Game.Database if (!loaded.IsSet) return; - // If this ever leads to performance issues, we could dequeue a limited number of operations per update frame. - while (pendingOperations.TryDequeue(out var op)) + if (pendingOperations.Count == 0) + return; + + lock (detachedBeatmapSets) { - switch (op.Type) + // If this ever leads to performance issues, we could dequeue a limited number of operations per update frame. + while (pendingOperations.TryDequeue(out var op)) { - case OperationType.Insert: - detachedBeatmapSets.Insert(op.Index, op.BeatmapSet!); - break; + switch (op.Type) + { + case OperationType.Insert: + detachedBeatmapSets.Insert(op.Index, op.BeatmapSet!); + break; - case OperationType.Update: - detachedBeatmapSets.ReplaceRange(op.Index, 1, new[] { op.BeatmapSet! }); - break; + case OperationType.Update: + detachedBeatmapSets.ReplaceRange(op.Index, 1, new[] { op.BeatmapSet! }); + break; - case OperationType.Remove: - detachedBeatmapSets.RemoveAt(op.Index); - break; + case OperationType.MoveAndReplace: + detachedBeatmapSets.Move(op.Index, op.NewIndex!.Value); + detachedBeatmapSets.ReplaceRange(op.NewIndex!.Value, 1, [op.BeatmapSet!]); + break; + + case OperationType.Remove: + detachedBeatmapSets.RemoveAt(op.Index); + break; + } } } } @@ -151,13 +193,15 @@ namespace osu.Game.Database public OperationType Type; public BeatmapSetInfo? BeatmapSet; public int Index; + public int? NewIndex; } private enum OperationType { Insert, Update, - Remove + Remove, + MoveAndReplace, } } } diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs index c84e1e35b8..1bb6b0aba4 100644 --- a/osu.Game/Database/RealmExtensions.cs +++ b/osu.Game/Database/RealmExtensions.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Logging; using Realms; namespace osu.Game.Database @@ -29,6 +30,7 @@ namespace osu.Game.Database // It may be that we access this from the update thread before a refresh has taken place. // To ensure that behaviour matches what we'd expect (the object generally *should be* available), force // a refresh to bring in any off-thread changes immediately. + Logger.Log($"{nameof(FindWithRefresh)} triggered a realm refresh because it couldn't find the requested guid {id}"); realm.Refresh(); found = realm.Find(id); } diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index df725505fc..2c4d36f7d0 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -10,10 +10,12 @@ using AutoMapper; using AutoMapper.Internal; using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Input.Bindings; using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Skinning; using Realms; namespace osu.Game.Database @@ -169,6 +171,7 @@ namespace osu.Game.Database }); c.CreateMap(); + c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); @@ -177,6 +180,7 @@ namespace osu.Game.Database c.CreateMap(); c.CreateMap(); c.CreateMap(); + c.CreateMap(); } /// @@ -266,7 +270,7 @@ namespace osu.Game.Database /// /// If a write transaction did not modify any objects in this , the callback is not invoked at all. /// If an error occurs the callback will be invoked with null for the sender parameter and a non-null error. - /// Currently the only errors that can occur are when opening the on the background worker thread. + /// Currently, the only errors that can occur are when opening the on the background worker thread. /// /// /// At the time when the block is called, the object will be fully evaluated @@ -285,8 +289,8 @@ namespace osu.Game.Database /// A subscription token. It must be kept alive for as long as you want to receive change notifications. /// To stop receiving notifications, call . /// - /// - /// + /// + /// #pragma warning restore RS0030 public static IDisposable QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) where T : RealmObjectBase diff --git a/osu.Game/Database/RealmResetEmptySet.cs b/osu.Game/Database/RealmResetEmptySet.cs index 9f9a1ba6d7..0daedc9633 100644 --- a/osu.Game/Database/RealmResetEmptySet.cs +++ b/osu.Game/Database/RealmResetEmptySet.cs @@ -46,7 +46,8 @@ namespace osu.Game.Database } public IRealmCollection Freeze() => throw new NotImplementedException(); - public IDisposable SubscribeForNotifications(NotificationCallbackDelegate callback) => throw new NotImplementedException(); + public IDisposable SubscribeForNotifications(NotificationCallbackDelegate callback, KeyPathsCollection? keyPathCollection = null) => throw new NotImplementedException(); + public bool IsValid => throw new NotImplementedException(); public Realm Realm => throw new NotImplementedException(); public ObjectSchema ObjectSchema => throw new NotImplementedException(); diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs index eef9b63b62..7c9d929999 100644 --- a/osu.Game/Extensions/ModelExtensions.cs +++ b/osu.Game/Extensions/ModelExtensions.cs @@ -2,21 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System.IO; -using System.Text.RegularExpressions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Extensions { public static class ModelExtensions { - private static readonly Regex invalid_filename_chars = new Regex(@"(?!$)[^A-Za-z0-9_()[\]. \-]", RegexOptions.Compiled); - /// /// Get the relative path in osu! storage for this file. /// @@ -155,14 +153,49 @@ namespace osu.Game.Extensions return instance.OnlineID.Equals(other.OnlineID); } + // intentionally chosen to match stable. + // see https://referencesource.microsoft.com/#mscorlib/system/io/path.cs,88 + private static readonly char[] invalid_filename_chars = + { + '\"', '<', '>', '|', '\0', (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10, (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, + (char)18, (char)19, (char)20, (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30, (char)31, ':', '*', '?', '\\', '/' + }; + /// /// Create a valid filename which should work across all platforms. /// /// - /// This function replaces all characters not included in a very pessimistic list which should be compatible - /// across all operating systems. We are using this in place of as - /// that function does not have per-platform considerations (and is only made to work on windows). + /// + /// We are using this in place of + /// as that function works per-platform, and therefore returns a different set of characters on different OSes. + /// + /// + /// Note that the behaviour of this method is LOAD-BEARING for things such as interoperability of beatmap exports with stable, + /// especially with respect to beatmap submission. + /// DO NOT CHANGE THE SEMANTICS OF THIS METHOD unless you know well what you are doing. + /// /// - public static string GetValidFilename(this string filename) => invalid_filename_chars.Replace(filename, "_"); + /// + public static string GetValidFilename(this string filename) + { + foreach (char c in invalid_filename_chars) + filename = filename.Replace(c.ToString(), string.Empty); + return filename; + } + + public static bool RequiresSupporter(this BeatmapLeaderboardScope scope, bool filterMods) + { + switch (scope) + { + case BeatmapLeaderboardScope.Local: + return false; + + case BeatmapLeaderboardScope.Country: + case BeatmapLeaderboardScope.Friend: + return true; + } + + return filterMods; + } } } diff --git a/osu.Game/Extensions/NumberFormattingExtensions.cs b/osu.Game/Extensions/NumberFormattingExtensions.cs new file mode 100644 index 0000000000..0c73590808 --- /dev/null +++ b/osu.Game/Extensions/NumberFormattingExtensions.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 System.Globalization; +using System.Numerics; +using osu.Game.Utils; + +namespace osu.Game.Extensions +{ + public static class NumberFormattingExtensions + { + /// + /// For a given numeric type, return a formatted string in the standard format we use for display everywhere. + /// + /// + /// Number formatting will abide by . + /// + /// The numeric value. + /// The maximum number of decimals to be considered in the original value. + /// Whether the output should be a percentage. For integer types, 0-100 is mapped to 0-100%; for other types 0-1 is mapped to 0-100%. + /// The formatted output. + public static string ToStandardFormattedString(this T value, int maxDecimalDigits, bool asPercentage = false) where T : struct, INumber, IMinMaxValue + { + double floatValue = double.CreateTruncating(value); + + decimal decimalPrecision = normalise(decimal.CreateTruncating(value), maxDecimalDigits); + + // Find the number of significant digits (we could have less than maxDecimalDigits after normalize()) + int significantDigits = FormatUtils.FindPrecision(decimalPrecision); + + if (asPercentage) + { + if (value is int) + floatValue /= 100; + + return floatValue.ToString($@"0.{new string('0', Math.Max(0, significantDigits - 2))}%", CultureInfo.CurrentCulture); + } + + string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? "-" : string.Empty; + + return $"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}", CultureInfo.CurrentCulture)}"; + } + + /// + /// Removes all non-significant digits, keeping at most a requested number of decimal digits. + /// + /// The decimal to normalize. + /// The maximum number of decimal digits to keep. The final result may have fewer decimal digits than this value. + /// The normalised decimal. + private static decimal normalise(decimal d, int sd) + => decimal.Parse(Math.Round(d, sd).ToString(string.Concat("0.", new string('#', sd)), CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + } +} diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs index 6f6febb646..b4be330f9c 100644 --- a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -28,7 +28,6 @@ namespace osu.Game.Graphics.Backgrounds [Resolved] private IAPIProvider api { get; set; } - private readonly IBindable apiState = new Bindable(); private Bindable seasonalBackgroundMode; private Bindable seasonalBackgrounds; @@ -47,13 +46,12 @@ namespace osu.Game.Graphics.Backgrounds SeasonalBackgroundChanged?.Invoke(); }); - apiState.BindTo(api.State); - apiState.BindValueChanged(fetchSeasonalBackgrounds, true); + fetchSeasonalBackgrounds(); } - private void fetchSeasonalBackgrounds(ValueChangedEvent stateChanged) + private void fetchSeasonalBackgrounds() { - if (seasonalBackgrounds.Value != null || stateChanged.NewValue != APIState.Online) + if (seasonalBackgrounds.Value != null) return; var request = new GetSeasonalBackgroundsRequest(); diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index e877915fac..d22aa197bb 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -127,8 +127,6 @@ namespace osu.Game.Graphics.Backgrounds { base.Update(); - Invalidate(Invalidation.DrawNode); - if (CreateNewTriangles) addTriangles(false); @@ -138,6 +136,10 @@ namespace osu.Game.Graphics.Backgrounds : 1; float elapsedSeconds = (float)Time.Elapsed / 1000; + + if (elapsedSeconds == 0) + return; + // Since position is relative, the velocity needs to scale inversely with DrawHeight. // Since we will later multiply by the scale of individual triangles we normalize by // dividing by triangleScale. @@ -157,6 +159,8 @@ namespace osu.Game.Graphics.Backgrounds if (bottomPos < 0) parts.RemoveAt(i); } + + Invalidate(Invalidation.DrawNode); } /// @@ -183,8 +187,13 @@ namespace osu.Game.Graphics.Backgrounds int currentCount = parts.Count; + if (AimCount - currentCount == 0) + return; + for (int i = 0; i < AimCount - currentCount; i++) parts.Add(createTriangle(randomY)); + + Invalidate(Invalidation.DrawNode); } private TriangleParticle createTriangle(bool randomY) diff --git a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs index 4143a6d76d..358e859cc8 100644 --- a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs +++ b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs @@ -91,12 +91,14 @@ namespace osu.Game.Graphics.Backgrounds { base.Update(); - Invalidate(Invalidation.DrawNode); - if (CreateNewTriangles) addTriangles(false); float elapsedSeconds = (float)Time.Elapsed / 1000; + + if (elapsedSeconds == 0) + return; + // Since position is relative, the velocity needs to scale inversely with DrawHeight. float movedDistance = -elapsedSeconds * Velocity * base_velocity / DrawHeight; @@ -112,6 +114,8 @@ namespace osu.Game.Graphics.Backgrounds if (bottomPos < 0) parts.RemoveAt(i); } + + Invalidate(Invalidation.DrawNode); } /// @@ -138,8 +142,13 @@ namespace osu.Game.Graphics.Backgrounds int currentCount = parts.Count; + if (AimCount - currentCount == 0) + return; + for (int i = 0; i < AimCount - currentCount; i++) parts.Add(createTriangle(randomY)); + + Invalidate(Invalidation.DrawNode); } private TriangleParticle createTriangle(bool randomY) diff --git a/osu.Game/Graphics/Carousel/Carousel.ScrollContainer.cs b/osu.Game/Graphics/Carousel/Carousel.ScrollContainer.cs new file mode 100644 index 0000000000..accd74aa4b --- /dev/null +++ b/osu.Game/Graphics/Carousel/Carousel.ScrollContainer.cs @@ -0,0 +1,301 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Graphics.Carousel +{ + /// + /// A highly efficient vertical list display that is used primarily for the song select screen, + /// but flexible enough to be used for other use cases. + /// + public abstract partial class Carousel where T : notnull + { + /// + /// Implementation of scroll container which handles very large vertical lists by internally using double precision + /// for pre-display Y values. + /// + protected partial class ScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler + { + public readonly Container Panels; + + public void SetLayoutHeight(float height) => Panels.Height = height; + + protected override ScrollbarContainer CreateScrollbar(Direction direction) => new ScrollBar(); + + /// + /// Allow handling right click scroll outside of the carousel's display area. + /// + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + public ScrollContainer() + { + // Managing our own custom layout within ScrollContent causes feedback with public ScrollContainer calculations, + // so we must maintain one level of separation from ScrollContent. + base.Add(Panels = new Container + { + Name = "Layout content", + RelativeSizeAxes = Axes.X, + }); + } + + public override void OffsetScrollPosition(double offset) + { + base.OffsetScrollPosition(offset); + + foreach (var panel in Panels) + ((ICarouselPanel)panel).DrawYPosition += offset; + } + + public override void Clear(bool disposeChildren) + { + Panels.Height = 0; + Panels.Clear(disposeChildren); + } + + public override void Add(Drawable drawable) + { + if (drawable is not ICarouselPanel) + throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}"); + + Panels.Add(drawable); + } + + public override double GetChildPosInContent(Drawable d, Vector2 offset) + { + if (d is not ICarouselPanel panel) + return base.GetChildPosInContent(d, offset); + + return panel.DrawYPosition + offset.X; + } + + protected override void ApplyCurrentToContent() + { + Debug.Assert(ScrollDirection == Direction.Vertical); + + double scrollableExtent = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y; + + foreach (var d in Panels) + d.Y = (float)(((ICarouselPanel)d).DrawYPosition + scrollableExtent); + } + + #region Scrollbar padding + + public float ScrollbarPaddingTop { get; set; } = 5; + public float ScrollbarPaddingBottom { get; set; } = 5; + + protected override float ToScrollbarPosition(double scrollPosition) + { + if (Precision.AlmostEquals(0, ScrollableExtent)) + return 0; + + return (float)(ScrollbarPaddingTop + (ScrollbarMovementExtent - (ScrollbarPaddingTop + ScrollbarPaddingBottom)) * (scrollPosition / ScrollableExtent)); + } + + protected override float FromScrollbarPosition(float scrollbarPosition) + { + if (Precision.AlmostEquals(0, ScrollbarMovementExtent)) + return 0; + + return (float)(ScrollableExtent * ((scrollbarPosition - ScrollbarPaddingTop) / (ScrollbarMovementExtent - (ScrollbarPaddingTop + ScrollbarPaddingBottom)))); + } + + #endregion + + #region Absolute scrolling + + /// + /// Whether absolute scrolling is currently triggered. + /// + public bool AbsoluteScrolling { get; private set; } + + protected override bool IsDragging => base.IsDragging || AbsoluteScrolling; + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + beginAbsoluteScrolling(e); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + endAbsoluteScrolling(); + break; + } + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + // To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over. + if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) + return false; + + beginAbsoluteScrolling(e); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button == MouseButton.Right) + endAbsoluteScrolling(); + base.OnMouseUp(e); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (AbsoluteScrolling) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + return true; + } + + return base.OnMouseMove(e); + } + + private void beginAbsoluteScrolling(UIEvent e) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + AbsoluteScrolling = true; + } + + private void endAbsoluteScrolling() => AbsoluteScrolling = false; + + #endregion + + #region Scrollbar + + private partial class ScrollBar : ScrollbarContainer + { + private Color4 hoverColour; + private Color4 defaultColour; + private Color4 highlightColour; + + private readonly Drawable box; + + protected override float MinimumDimSize => SCROLL_BAR_WIDTH * 3; + + private const float expanded_size_ratio = 2; + + public ScrollBar() + : base(Direction.Vertical) + { + Blending = BlendingParameters.Additive; + + // needs to be set initially for the ResizeTo to respect minimum size + Size = new Vector2(SCROLL_BAR_WIDTH * expanded_size_ratio, SCROLL_BAR_WIDTH); + + const float margin = 3; + + Margin = new MarginPadding + { + Left = margin, + Right = margin, + }; + + Child = box = new Circle + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + Width = 1 / expanded_size_ratio, + }; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) + { + Colour = defaultColour = colours.Gray8; + hoverColour = colours.GrayF; + highlightColour = colourProvider?.Highlight1 ?? colours.Green; + } + + public override void ResizeTo(float val, int duration = 0, Easing easing = Easing.None) + { + this.ResizeTo(new Vector2(SCROLL_BAR_WIDTH * expanded_size_ratio) + { + [(int)ScrollDirection] = val + }, duration, easing); + } + + protected override bool OnHover(HoverEvent e) + { + updateVisuals(e); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateVisuals(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!base.OnMouseDown(e)) return false; + + updateVisuals(e); + return true; + } + + protected override void OnDragEnd(DragEndEvent e) + { + updateVisuals(e); + base.OnDragEnd(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button != MouseButton.Left) return; + + updateVisuals(e); + base.OnMouseUp(e); + } + + private void updateVisuals(MouseEvent e) + { + if (IsDragged || e.PressedButtons.Contains(MouseButton.Left)) + box.FadeColour(highlightColour, 100); + else if (IsHovered) + box.FadeColour(hoverColour, 100); + else + box.FadeColour(defaultColour, 100); + + if (IsHovered || IsDragged) + box.ResizeWidthTo(1, 300, Easing.OutElasticHalf); + else + box.ResizeWidthTo(1 / expanded_size_ratio, 200, Easing.OutQuint); + } + } + + #endregion + } + } +} diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs new file mode 100644 index 0000000000..be1c013478 --- /dev/null +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -0,0 +1,1102 @@ +// Copyright (c) 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.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Development; +using osu.Framework.Extensions.PolygonExtensions; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Layout; +using osu.Framework.Logging; +using osu.Framework.Utils; +using osu.Game.Input.Bindings; +using osu.Game.Online.Multiplayer; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Graphics.Carousel +{ + /// + /// A highly efficient vertical list display that is used primarily for the song select screen, + /// but flexible enough to be used for other use cases. + /// + public abstract partial class Carousel : CompositeDrawable, IKeyBindingHandler + where T : notnull + { + #region Properties and methods for external usage + + /// + /// Called after a filter operation or change in items results in the visible carousel items changing. + /// + public Action>? NewItemsPresented { private get; init; } + + /// + /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedTop { get; set; } + + /// + /// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedBottom { get; set; } + + /// + /// The number of pixels outside the carousel's vertical bounds to manifest drawables. + /// This allows preloading content before it scrolls into view. + /// + public float DistanceOffscreenToPreload { get; set; } + + /// + /// When a new request arrives to change filtering, the number of milliseconds to wait before performing the filter. + /// Regardless of any external debouncing, this is a safety measure to avoid triggering too many threaded operations. + /// + public int DebounceDelay { get; set; } + + /// + /// Whether an asynchronous filter / group operation is currently underway. + /// + public bool IsFiltering => !filterTask.IsCompleted; + + /// + /// Whether absolute scrolling is currently triggered. + /// + public bool AbsoluteScrolling => Scroll.AbsoluteScrolling; + + /// + /// The number of times filter operations have been triggered. + /// + public int FilterCount { get; private set; } + + /// + /// The number of displayable items currently being tracked (before filtering). + /// + public int ItemsTracked => Items.Count; + + /// + /// The items currently in rotation for display. + /// + public int DisplayableItems => carouselItems?.Count ?? 0; + + /// + /// The number of items currently actualised into drawables. + /// + public int VisibleItems => Scroll.Panels.Count; + + /// + /// The currently selected model. Generally of type T. + /// + /// + /// A carousel may create panels for non-T types. + /// To keep things simple, we therefore avoid generic constraints on the current selection. + /// + /// The selection is never reset due to not existing. It can be set to anything. + /// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches. + /// + protected object? CurrentSelection + { + get => currentSelection.Model; + set + { + if (!CheckModelEquality(currentSelection.Model, value)) + { + HandleItemSelected(value); + + if (currentSelection.Model != null) + HandleItemDeselected(currentSelection.Model); + + currentSelection = new Selection(value); + currentKeyboardSelection = currentSelection; + selectionValid.Invalidate(); + } + + // Check keyboard selection equality separately. + // + // If current selection set to an already-selected value, we want to ensure + // that keyboard selection (which basically represents the "visual" tracking of selection) + // is still reset back to the newly set value. + // + // The main case this handles is when a set header is clicked and we want to make sure one of its + // "children" are re-selected. + if (!CheckModelEquality(currentKeyboardSelection.Model, value)) + { + currentKeyboardSelection = currentSelection; + selectionValid.Invalidate(); + } + } + } + + /// + /// Activate the specified item. + /// + /// + public void Activate(CarouselItem item) + { + // Regardless of how the item handles activation, update keyboard selection to the activated panel. + // In other words, when a panel is clicked, keyboard selection should default to matching the clicked + // item. + setKeyboardSelection(item.Model); + + (GetMaterialisedDrawableForItem(item) as ICarouselPanel)?.Activated(); + HandleItemActivated(item); + + selectionValid.Invalidate(); + } + + /// + /// Scroll carousel to the selected item if available. + /// + /// + /// Whether the scroll position should immediately be shifted to the target, delegating animation to visible panels. + /// This should be true for operations like filtering - where panels are changing visibility state - to avoid large jumps in animation. + /// + public void ScrollToSelection(bool immediate = false) + { + // if an immediate scroll is already requested, don't override it with a slower scroll + if (scrollToSelection == PendingScrollOperation.Immediate) + return; + + scrollToSelection = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard; + } + + /// + /// Returns the vertical spacing between two given carousel items. Negative value can be used to create an overlapping effect. + /// + protected virtual float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) => 0f; + + #endregion + + #region Properties and methods concerning implementations + + /// + /// A collection of filters which should be run each time a is executed. + /// + /// + /// Implementations should add all required filters as part of their initialisation. + /// + /// Importantly, each filter is sequentially run in the order provided. + /// Each filter receives the output of the previous filter. + /// + /// A filter may add, mutate or remove items. + /// + public IEnumerable Filters { get; init; } = Enumerable.Empty(); + + /// + /// All items which are to be considered for display in this carousel. + /// Mutating this list will automatically queue a . + /// + /// + /// Note that an may add new items which are displayed but not tracked in this list. + /// + protected readonly BindableList Items = new BindableList(); + + /// + /// Queue an asynchronous filter operation. + /// + /// Whether all existing drawable panels should be reset post filter. + protected virtual Task> FilterAsync(bool clearExistingPanels = false) + { + FilterCount++; + + if (clearExistingPanels) + filterReusesPanels.Invalidate(); + + filterAfterItemsChanged.Validate(); + + filterTask = performFilter(); + filterTask.FireAndForget(); + return filterTask; + } + + /// + /// Called when changes in any way. + /// + /// Whether a re-filter is required. + protected virtual bool HandleItemsChanged(NotifyCollectionChangedEventArgs args) => true; + + /// + /// Fired after a filter operation completed. + /// + protected virtual void HandleFilterCompleted() + { + } + + /// + /// Check whether two models are the same for display purposes. + /// + protected virtual bool CheckModelEquality(object? x, object? y) => ReferenceEquals(x, y); + + /// + /// Create a drawable for the given carousel item so it can be displayed. + /// + /// + /// For efficiency, it is recommended the drawables are retrieved from a . + /// + /// The item which should be represented by the returned drawable. + /// The manifested drawable. + protected abstract Drawable GetDrawableForDisplay(CarouselItem item); + + /// + /// Given a , find a drawable representation if it is currently displayed in the carousel. + /// + /// + /// This will only return a drawable if it is "on-screen". + /// + /// The item to find a related drawable representation. + /// The drawable representation if it exists. + protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) => + Scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item); + + /// + /// When a user is traversing the carousel via group selection keys, assert whether the item provided is a valid target. + /// + /// The candidate item. + /// Whether the provided item is a valid group target. If false, more panels will be checked in the user's requested direction until a valid target is found. + protected virtual bool CheckValidForGroupSelection(CarouselItem item) => false; + + /// + /// When a user is traversing the carousel via set selection keys, assert whether the item provided is a valid target. + /// + /// The candidate item. + /// Whether the provided item is a valid set target. If false, more panels will be checked in the user's requested direction until a valid target is found. + protected virtual bool CheckValidForSetSelection(CarouselItem item) => true; + + /// + /// Keyboard selection usually does not automatically activate an item. There may be exceptions to this rule. + /// Returning true here will make keyboard traversal act like set traversal for the target item. + /// + protected virtual bool ShouldActivateOnKeyboardSelection(CarouselItem item) => false; + + /// + /// Called after an item becomes the . + /// Should be used to handle any set expansion, item visibility changes, etc. + /// + protected virtual void HandleItemSelected(object? model) { } + + /// + /// Called when the changes to a new selection. + /// Should be used to handle any set expansion, item visibility changes, etc. + /// + protected virtual void HandleItemDeselected(object? model) { } + + /// + /// Called when an item is activated via user input (keyboard traversal or a mouse click). + /// + /// + /// An activated item should decide to perform an action, such as: + /// - Change its expanded state (and show / hide children items). + /// - Set the item to the . + /// - Start gameplay on a beatmap difficulty if already selected. + /// + /// The carousel item which was activated. + protected virtual void HandleItemActivated(CarouselItem item) { } + + #endregion + + #region Initialisation + + protected readonly ScrollContainer Scroll; + + protected Carousel() + { + InternalChild = Scroll = new ScrollContainer + { + Masking = false, + RelativeSizeAxes = Axes.Both, + }; + + Items.BindCollectionChanged((_, args) => + { + if (HandleItemsChanged(args)) + filterAfterItemsChanged.Invalidate(); + }); + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + loadSamples(audio); + } + + #endregion + + #region Filtering and display preparation + + /// + /// Retrieve a list of all s currently displayed. + /// + public IList? GetCarouselItems() => carouselItems; + + private List? carouselItems; + + private Task> filterTask = Task.FromResult(Enumerable.Empty()); + private CancellationTokenSource cancellationSource = new CancellationTokenSource(); + + /// + /// For background re-filters, ensure we wait for the previous filter operation to complete before starting another. + /// This avoids the carousel never updating its display in high churn scenarios. + /// + private readonly Cached filterAfterItemsChanged = new Cached(); + + private async Task> performFilter() + { + Stopwatch stopwatch = Stopwatch.StartNew(); + var cts = new CancellationTokenSource(); + + var previousCancellationSource = Interlocked.Exchange(ref cancellationSource, cts); + await previousCancellationSource.CancelAsync().ConfigureAwait(true); + + if (DebounceDelay > 0) + { + log($"Filter operation queued, waiting for {DebounceDelay} ms debounce"); + await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(true); + } + + // Copy must be performed on update thread for now (see ConfigureAwait above). + // Could potentially be optimised in the future if it becomes an issue. + Debug.Assert(ThreadSafety.IsUpdateThread); + List items = new List(Items.Select(m => new CarouselItem(m))); + + await Task.Run(async () => + { + try + { + foreach (var filter in Filters) + { + log($"Performing {filter.GetType().ReadableName()}"); + items = await filter.Run(items, cts.Token).ConfigureAwait(false); + } + + log("Updating Y positions"); + updateYPositions(items, visibleHalfHeight); + } + catch (OperationCanceledException) + { + log("Cancelled due to newer request arriving"); + } + }, cts.Token).ConfigureAwait(false); + + if (cts.Token.IsCancellationRequested) + return Enumerable.Empty(); + + Schedule(() => + { + log("Items ready for display"); + carouselItems = items; + displayedRange = null; + + if (!filterReusesPanels.IsValid) + { + foreach (var panel in Scroll.Panels) + expirePanel(panel); + + filterReusesPanels.Validate(); + } + + HandleFilterCompleted(); + + refreshAfterSelection(); + if (!Scroll.UserScrolling) + ScrollToSelection(immediate: true); + + NewItemsPresented?.Invoke(carouselItems); + }); + + return items; + + void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}"); + } + + private void updateYPositions(IEnumerable carouselItems, float offset) + { + CarouselItem? previousVisible = null; + + foreach (var item in carouselItems) + updateItemYPosition(item, ref previousVisible, ref offset); + } + + private void updateItemYPosition(CarouselItem item, ref CarouselItem? previousVisible, ref float offset) + { + float spacing = previousVisible == null || !item.IsVisible ? 0 : GetSpacingBetweenPanels(previousVisible, item); + + offset += spacing; + item.CarouselYPosition = offset; + + // ensure there are no input gaps where clicking will fall through the carousel. + // notably, only do this where there's positive spacing between panels (negative spacing means they overlap already and there is no gap to fill). + if (spacing > 0) + { + item.CarouselInputLenienceAbove = spacing / 2; + if (previousVisible != null) + previousVisible.CarouselInputLenienceBelow = item.CarouselInputLenienceAbove; + } + + if (item.IsVisible) + { + offset += item.DrawHeight; + previousVisible = item; + } + } + + #endregion + + #region Input handling + + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + // this is a special hard-coded case; we can't rely on OnPressed as GlobalActionContainer is + // matching with exact modifier consideration (so Ctrl+Enter would be ignored). + case Key.Enter: + case Key.KeypadEnter: + return activateSelection(); + } + + return base.OnKeyDown(e); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.Select: + return activateSelection(); + + // the selection traversal handlers below are scheduled to avoid an issue + // wherein if the update frame rate is low, keeping one of the actions below pressed leads to selection moving back to the start / end. + // the reason why that happens is that the code managing `current(Keyboard)?Selection` can lose track of the index of the selected item + // if the selection is changed more than once during an update frame, + // which can happen if repeat inputs are enqueued for processing at a rate faster than the update refresh rate. + // `refreshAfterSelection()` is the method responsible for updating the index of the selected item here which runs once per frame. + case GlobalAction.SelectPrevious: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, -1)); + return true; + + case GlobalAction.SelectNext: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, 1)); + return true; + + case GlobalAction.ActivatePreviousSet: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, -1)); + return true; + + case GlobalAction.ActivateNextSet: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, 1)); + return true; + + case GlobalAction.ExpandPreviousGroup: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Group, -1)); + return true; + + case GlobalAction.ExpandNextGroup: + Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Group, 1)); + return true; + + case GlobalAction.ToggleCurrentGroup: + if (carouselItems == null || carouselItems.Count == 0) + return true; + + if (currentKeyboardSelection.CarouselItem == null || currentKeyboardSelection.Index == null) + return true; + + if (CheckValidForGroupSelection(currentKeyboardSelection.CarouselItem)) + { + // If keyboard selection is a group, toggle group and then change keyboard selection to actual selection. + Activate(currentKeyboardSelection.CarouselItem); + } + else + { + // If current keyboard selection is not a group, toggle the closest group and move keyboard selection to that group. + for (int i = currentKeyboardSelection.Index.Value; i >= 0; i--) + { + var newItem = carouselItems[i]; + + if (CheckValidForGroupSelection(newItem)) + { + Activate(newItem); + return true; + } + } + } + + return true; + } + + return false; + + void traverseFromKey(TraversalOperation traversal) + { + switch (traversal.Type) + { + case TraversalType.Keyboard: + traverseKeyboardSelection(traversal.Direction); + break; + + case TraversalType.Set: + traverseSetSelection(traversal.Direction); + break; + + case TraversalType.Group: + traverseGroupSelection(traversal.Direction); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private enum TraversalType { Keyboard, Set, Group } + + private record TraversalOperation(TraversalType Type, int Direction); + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + private bool activateSelection() + { + if (currentKeyboardSelection.CarouselItem != null) + { + Activate(currentKeyboardSelection.CarouselItem); + return true; + } + + return false; + } + + private void traverseKeyboardSelection(int direction) + { + if (carouselItems == null || carouselItems.Count == 0) return; + + int originalIndex; + + if (currentKeyboardSelection.Index != null) + originalIndex = currentKeyboardSelection.Index.Value; + else if (direction > 0) + originalIndex = carouselItems.Count - 1; + else + originalIndex = 0; + + int newIndex = originalIndex; + + // Iterate over every item back to the current selection, finding the first valid item. + // The fail condition is when we reach the selection after a cyclic loop over every item. + do + { + newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count; + var newItem = carouselItems[newIndex]; + + if (newItem.IsVisible) + { + if (!CheckModelEquality(currentSelection.Model, newItem.Model) && ShouldActivateOnKeyboardSelection(newItem)) + Activate(newItem); + else + { + playTraversalSound(); + setKeyboardSelection(newItem.Model); + } + + return; + } + } while (newIndex != originalIndex); + } + + /// + /// Select the next valid group selection relative to a current selection. + /// This is generally for keyboard based traversal. + /// + /// Positive for downwards, negative for upwards. + /// Whether selection was possible. + private void traverseGroupSelection(int direction) => traverseSelection(direction, CheckValidForGroupSelection); + + /// + /// Select the next valid set selection relative to a current selection. + /// This is generally for keyboard based traversal. + /// + /// Positive for downwards, negative for upwards. + /// Whether selection was possible. + private void traverseSetSelection(int direction) + { + // If the user has a different keyboard selection and requests + // set selection, first transfer the keyboard selection to actual selection. + // + // It is assumed that selecting a set will immediately change selection to one of its children. + if (currentKeyboardSelection.CarouselItem != null && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem) + { + Activate(currentKeyboardSelection.CarouselItem); + return; + } + + traverseSelection(direction, CheckValidForSetSelection); + } + + private void traverseSelection(int direction, Func predicate) + { + if (carouselItems == null || carouselItems.Count == 0) return; + + int originalIndex; + int newIndex; + + if (currentKeyboardSelection.Index == null) + { + // If there's no current selection, start from either end of the full list. + newIndex = originalIndex = direction > 0 ? carouselItems.Count - 1 : 0; + } + else + { + newIndex = originalIndex = currentKeyboardSelection.Index.Value; + + // As a second special case, if we're set selecting backwards and the current selection isn't a set, + // make sure to go back to the set header this item belongs to, so that the block below doesn't find it and stop too early. + if (direction < 0) + { + while (newIndex > 0 && !predicate(carouselItems[newIndex])) + newIndex--; + } + } + + // Iterate over every item back to the current selection, finding the first valid item. + // The fail condition is when we reach the selection after a cyclic loop over every item. + do + { + newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count; + + if (newIndex == originalIndex) + break; + + var newItem = carouselItems[newIndex]; + + if (!newItem.IsExpanded && predicate(newItem)) + { + Activate(newItem); + return; + } + } while (true); + } + + #endregion + + #region Scrolling + + /// + /// Scrolling to selection relies on being fully populated. + /// This flag ensures it runs after validates this. + /// + private PendingScrollOperation scrollToSelection = PendingScrollOperation.None; + + private enum PendingScrollOperation + { + None, + Standard, + Immediate, + } + + #endregion + + #region Audio + + private Sample? sampleKeyboardTraversal; + + private double audioFeedbackLastPlaybackTime; + + private void loadSamples(AudioManager audio) + { + sampleKeyboardTraversal = audio.Samples.Get(@"SongSelect/select-difficulty"); + } + + private void playTraversalSound() + { + if (Time.Current - audioFeedbackLastPlaybackTime >= OsuGameBase.SAMPLE_DEBOUNCE_TIME) + { + sampleKeyboardTraversal?.Play(); + audioFeedbackLastPlaybackTime = Time.Current; + } + } + + #endregion + + #region Selection handling + + /// + /// The currently selected , if any is selected. + /// + protected CarouselItem? CurrentSelectionItem => currentSelection.CarouselItem; + + /// + /// The index in of the current selection, if available. + /// + protected int? CurrentSelectionIndex => currentSelection.Index; + + /// + /// Becomes invalid when the current selection has changed and needs to be updated visually. + /// + private readonly Cached selectionValid = new Cached(); + + private Selection currentKeyboardSelection = new Selection(); + private Selection currentSelection = new Selection(); + + private void setKeyboardSelection(object? model) + { + currentKeyboardSelection = new Selection(model); + selectionValid.Invalidate(); + } + + /// + /// Call after a selection of items change to re-attach s to current s. + /// + private void refreshAfterSelection() + { + float yPos = visibleHalfHeight; + + // Invalidate display range as panel positions and visible status may have changed. + // Position transfer won't happen unless we invalidate this. + displayedRange = null; + + Selection prevKeyboard = currentKeyboardSelection; + + // Importantly, we also reset the `Selection` to the most basic state. + // Removing the index and carousel item here is important to ensure we are aware of if a selection has been filtered away. + // If it hasn't been filtered, the full details will be re-populated just below in the loop. + currentKeyboardSelection = new Selection(currentKeyboardSelection.Model); + currentSelection = new Selection(currentSelection.Model); + + if (carouselItems == null) + return; + + CarouselItem? lastVisible = null; + int count = carouselItems.Count; + + // We are performing two important operations here: + // - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions. + // - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use. + FindCarouselItemsForSelection(ref currentKeyboardSelection, ref currentSelection, carouselItems); + + for (int i = 0; i < count; i++) + { + var item = carouselItems[i]; + updateItemYPosition(item, ref lastVisible, ref yPos); + } + + if (currentKeyboardSelection.CarouselItem is CarouselItem currentKeyboardSelectionItem) + currentKeyboardSelection = currentKeyboardSelection with { YPosition = currentKeyboardSelectionItem.CarouselYPosition + currentKeyboardSelectionItem.DrawHeight / 2 }; + + if (currentSelection.CarouselItem is CarouselItem currentSelectionItem) + currentSelection = currentSelection with { YPosition = currentSelectionItem.CarouselYPosition + currentSelectionItem.DrawHeight / 2 }; + + // Update the total height of all items (to make the scroll container scrollable through the full height even though + // most items are not displayed / loaded). + Scroll.SetLayoutHeight(yPos + visibleHalfHeight); + + // If a keyboard selection is currently made, we want to keep the view stable around the selection. + // That means that we should offset the immediate scroll position by any change in Y position for the selection. + if (prevKeyboard.YPosition != null && currentKeyboardSelection.YPosition != null && currentKeyboardSelection.YPosition != prevKeyboard.YPosition) + Scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value)); + } + + protected virtual void FindCarouselItemsForSelection(ref Selection keyboardSelection, ref Selection selection, IList items) + { + for (int i = 0; i < items.Count; i++) + { + var item = items[i]; + + bool isKeyboardSelection = CheckModelEquality(item.Model, keyboardSelection.Model!); + bool isSelection = CheckModelEquality(item.Model, selection.Model!); + + // while we don't know the Y position of the item yet, as it's about to be updated, + // consumers (specifically `BeatmapCarousel.GetSpacingBetweenPanels()`) benefit from `CurrentSelectionItem` already pointing + // at the correct item to avoid redundant local equality checks. + // the Y positions will be filled in after they're computed. + if (isKeyboardSelection) + keyboardSelection = new Selection(keyboardSelection.Model, item, null, i); + + if (isSelection) + selection = new Selection(selection.Model, item, null, i); + } + } + + #endregion + + #region Display handling + + private DisplayRange? displayedRange; + + private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object()); + + /// + /// The position of the lower visible bound with respect to the current scroll position. + /// + private float visibleBottomBound; + + /// + /// The position of the upper visible bound with respect to the current scroll position. + /// + private float visibleUpperBound; + + /// + /// Half the height of the visible content. + /// + private float visibleHalfHeight; + + /// + /// Whether existing panels can be re-used in the next filter. + /// + private readonly Cached filterReusesPanels = new Cached(); + + protected override void Update() + { + base.Update(); + + if (carouselItems == null) + return; + + visibleBottomBound = (float)(Scroll.Current + DrawHeight + BleedBottom); + visibleUpperBound = (float)(Scroll.Current - BleedTop); + visibleHalfHeight = (DrawHeight + BleedBottom + BleedTop) / 2; + + if (!selectionValid.IsValid) + { + refreshAfterSelection(); + + // Always scroll to selection in this case (regardless of `UserScrolling` state), centering the selection. + ScrollToSelection(); + + selectionValid.Validate(); + } + + var range = getDisplayRange(); + + if (range != displayedRange) + { + displayedRange = range; + updateDisplayedRange(range); + } + + double selectedYPos = currentSelection.CarouselItem?.CarouselYPosition ?? 0; + + foreach (var panel in Scroll.Panels) + { + var c = (ICarouselPanel)panel; + + // panel in the process of expiring, ignore it. + if (c.Item == null) + continue; + + float normalisedDepth = (float)(Math.Abs(selectedYPos - c.Item.CarouselYPosition) / DrawHeight); + Scroll.Panels.ChangeChildDepth(panel, c.Item.DepthLayer + normalisedDepth); + + if (c.DrawYPosition != c.Item.CarouselYPosition) + c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed); + + panel.X = GetPanelXOffset(panel); + + c.Selected.Value = currentSelection?.CarouselItem != null && CheckModelEquality(c.Item, currentSelection.CarouselItem); + c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem; + c.Expanded.Value = c.Item.IsExpanded; + } + + if (!filterAfterItemsChanged.IsValid && !IsFiltering) + FilterAsync(); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (scrollToSelection != PendingScrollOperation.None) + { + if (GetScrollTarget() is double scrollTarget) + Scroll.ScrollTo(scrollTarget - visibleHalfHeight + BleedTop, animated: scrollToSelection == PendingScrollOperation.Standard); + + scrollToSelection = PendingScrollOperation.None; + } + } + + /// + /// Returns the Y position to scroll to in order to show the most relevant carousel item(s). + /// + protected virtual double? GetScrollTarget() => currentKeyboardSelection.YPosition; + + protected virtual float GetPanelXOffset(Drawable panel) + { + Vector2 posInScroll = Scroll.ToLocalSpace(panel.ScreenSpaceDrawQuad.Centre); + float dist = Math.Abs(1f - (posInScroll.Y + BleedTop) / visibleHalfHeight); + + return offsetX(dist, visibleHalfHeight); + } + + /// + /// Computes the x-offset of currently visible items. Makes the carousel appear round. + /// + /// + /// Vertical distance from the center of the carousel container + /// ranging from -1 to 1. + /// + /// Half the height of the carousel container. + private static float offsetX(float dist, float halfHeight) + { + // The radius of the circle the carousel moves on. + const float circle_radius = 3; + float discriminant = MathF.Max(0, circle_radius * circle_radius - dist * dist); + return (circle_radius - MathF.Sqrt(discriminant)) * halfHeight; + } + + private DisplayRange getDisplayRange() + { + Debug.Assert(carouselItems != null); + + if (carouselItems.Count == 0) + return DisplayRange.EMPTY; + + // Find index range of all items that should be on-screen + carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload; + int firstIndex = carouselItems.BinarySearch(carouselBoundsItem); + if (firstIndex < 0) firstIndex = ~firstIndex; + + carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload; + int lastIndex = carouselItems.BinarySearch(carouselBoundsItem); + if (lastIndex < 0) lastIndex = ~lastIndex; + + firstIndex = Math.Max(0, firstIndex - 1); + lastIndex = Math.Max(0, lastIndex - 1); + + return new DisplayRange(firstIndex, lastIndex); + } + + private void updateDisplayedRange(DisplayRange range) + { + Debug.Assert(carouselItems != null); + + List toDisplay = range == DisplayRange.EMPTY + ? new List() + : carouselItems.GetRange(range.First, range.Last - range.First + 1); + + toDisplay.RemoveAll(i => !i.IsVisible); + + // Iterate over all panels which are already displayed and figure which need to be displayed / removed. + foreach (var panel in Scroll.Panels) + { + var carouselPanel = (ICarouselPanel)panel; + + if (carouselPanel.Item == null) + { + // Item is null when a panel is already fading away from existence; should be ignored for tracking purposes. + continue; + } + + var existing = toDisplay.FirstOrDefault(i => CheckModelEquality(i.Model, carouselPanel.Item!.Model)); + + if (existing != null) + { + carouselPanel.Item = existing; + toDisplay.Remove(existing); + continue; + } + + // If the new display range doesn't contain the panel, it's no longer required for display. + expirePanel(panel); + } + + // Add any new items which need to be displayed and haven't yet. + foreach (var item in toDisplay) + { + var drawable = GetDrawableForDisplay(item); + + if (drawable is not ICarouselPanel carouselPanel) + throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}"); + + carouselPanel.Item = item; + carouselPanel.DrawYPosition = item.CarouselYPosition; + + Scroll.Add(drawable); + } + + if (toDisplay.Any()) + { + // To make transitions of items appearing in the flow look good, do a pass and make sure newly added items spawn from + // just beneath the *current interpolated position* of the previous panel. + var orderedPanels = Scroll.Panels + .Where(p => Scroll.ScreenSpaceDrawQuad.Intersects(p.ScreenSpaceDrawQuad)) + .OfType() + .Where(p => p.Item != null) + .OrderBy(p => p.Item!.CarouselYPosition) + .ToList(); + + for (int i = 0; i < orderedPanels.Count; i++) + { + var panel = orderedPanels[i]; + + if (toDisplay.Contains(panel.Item!)) + { + // Don't apply to the last because animating the tail of the list looks bad. + // It's usually off-screen anyway. + if (i > 0 && i < orderedPanels.Count - 1) + panel.DrawYPosition = orderedPanels[i - 1].DrawYPosition; + } + } + } + } + + private void expirePanel(Drawable panel) + { + var carouselPanel = (ICarouselPanel)panel; + + // expired panels should have a depth behind all other panels to make the transition not look weird. + Scroll.Panels.ChangeChildDepth(panel, panel.Depth + 1024); + + panel.FadeOut(150, Easing.OutQuint); + panel.MoveToX(panel.X + 100, 200, Easing.Out); + + panel.Expire(); + + carouselPanel.Item = null; + carouselPanel.Selected.Value = false; + carouselPanel.KeyboardSelected.Value = false; + carouselPanel.Expanded.Value = false; + } + + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + // handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed). + if (invalidation.HasFlag(Invalidation.DrawSize)) + selectionValid.Invalidate(); + + return base.OnInvalidate(invalidation, source); + } + + #endregion + + #region Internal helper classes + + /// + /// Bookkeeping for a current selection. + /// + /// The selected model. If null, there's no selection. + /// A related carousel item representation for the model. May be null if selection is not present as an item, or if has not been run yet. + /// The Y position of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. + /// The index of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. + protected record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null); + + private record DisplayRange(int First, int Last) + { + public static readonly DisplayRange EMPTY = new DisplayRange(-1, -1); + } + + #endregion + } +} diff --git a/osu.Game/Graphics/Carousel/CarouselItem.cs b/osu.Game/Graphics/Carousel/CarouselItem.cs new file mode 100644 index 0000000000..e1e93dd036 --- /dev/null +++ b/osu.Game/Graphics/Carousel/CarouselItem.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 System; + +namespace osu.Game.Graphics.Carousel +{ + /// + /// Represents a single display item for display in a . + /// This is used to house information related to the attached model that helps with display and tracking. + /// + public sealed class CarouselItem : IComparable + { + public const float DEFAULT_HEIGHT = 45; + + /// + /// The model this item is representing. + /// + public readonly object Model; + + /// + /// The current Y position in the carousel. + /// + /// This is managed by and should not be set manually. + /// + public double CarouselYPosition { get; set; } + + /// + /// The amount of input padding/lenience to be added to the area above this panel. + /// Calculated as half of the calculated spacing between this panel and the panel above it. + /// + /// This is managed by and should not be set manually. + /// + public float CarouselInputLenienceAbove { get; set; } + + /// + /// The amount of input padding/lenience to be added to the area below this panel. + /// Calculated as half of the calculated spacing between this panel and the panel below it. + /// + /// This is managed by and should not be set manually. + /// + public float CarouselInputLenienceBelow { get; set; } + + /// + /// The height this item will take when displayed. Defaults to . + /// + public float DrawHeight { get; set; } = DEFAULT_HEIGHT; + + /// + /// Defines the display depth relative to other s. + /// + public int DepthLayer { get; set; } + + /// + /// Whether this item is visible or hidden. + /// + public bool IsVisible { get; set; } = true; + + /// + /// Whether this item is expanded or not. Should only be used for headers of groups. + /// + public bool IsExpanded { get; set; } + + /// + /// The number of nested items underneath this header. Should only be used for headers of groups. + /// + public int NestedItemCount { get; set; } + + public CarouselItem(object model) + { + Model = model; + } + + public int CompareTo(CarouselItem? other) + { + if (other == null) return 1; + + return CarouselYPosition.CompareTo(other.CarouselYPosition); + } + } +} diff --git a/osu.Game/Graphics/Carousel/ICarouselFilter.cs b/osu.Game/Graphics/Carousel/ICarouselFilter.cs new file mode 100644 index 0000000000..a498c0ebc2 --- /dev/null +++ b/osu.Game/Graphics/Carousel/ICarouselFilter.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace osu.Game.Graphics.Carousel +{ + /// + /// An interface representing a filter operation which can be run on a . + /// + public interface ICarouselFilter + { + /// + /// Execute the filter operation. + /// + /// The items to be filtered. + /// A cancellation token. + /// The post-filtered items. + Task> Run(IEnumerable items, CancellationToken cancellationToken); + + /// + /// The total number of beatmap difficulties displayed post filter. + /// + int BeatmapItemsCount { get; } + } +} diff --git a/osu.Game/Graphics/Carousel/ICarouselPanel.cs b/osu.Game/Graphics/Carousel/ICarouselPanel.cs new file mode 100644 index 0000000000..5f0ebc263c --- /dev/null +++ b/osu.Game/Graphics/Carousel/ICarouselPanel.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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; + +namespace osu.Game.Graphics.Carousel +{ + /// + /// An interface to be attached to any s which are used for display inside a . + /// Importantly, all properties in this interface are managed by and should not be written to elsewhere. + /// + public interface ICarouselPanel + { + /// + /// Whether this item has selection (see ). Should be read from to update the visual state. + /// + BindableBool Selected { get; } + + /// + /// Whether this item is expanded (see ). Should be read from to update the visual state. + /// + BindableBool Expanded { get; } + + /// + /// Whether this item has keyboard selection. Should be read from to update the visual state. + /// + BindableBool KeyboardSelected { get; } + + /// + /// Called when the panel is activated. Should be used to update the panel's visual state. + /// + void Activated(); + + /// + /// The Y position used internally for positioning in the carousel. + /// + double DrawYPosition { get; set; } + + /// + /// The carousel item this drawable is representing. Will be set before is called. + /// + CarouselItem? Item { get; set; } + } +} diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 7210371ebf..4331b91e61 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -73,6 +73,16 @@ namespace osu.Game.Graphics.Containers /// protected bool IsBeatSyncedWithTrack { get; private set; } + /// + /// The most valid timing point, updated every frame. + /// + protected TimingControlPoint TimingPoint { get; private set; } = TimingControlPoint.DEFAULT; + + /// + /// The most valid effect point, updated every frame. + /// + protected EffectControlPoint EffectPoint { get; private set; } = EffectControlPoint.DEFAULT; + [Resolved] protected IBeatSyncProvider BeatSyncSource { get; private set; } = null!; @@ -82,9 +92,6 @@ namespace osu.Game.Graphics.Containers protected override void Update() { - TimingControlPoint timingPoint; - EffectControlPoint effectPoint; - IsBeatSyncedWithTrack = BeatSyncSource.Clock.IsRunning; double currentTrackTime; @@ -102,8 +109,8 @@ namespace osu.Game.Graphics.Containers currentTrackTime = BeatSyncSource.Clock.CurrentTime + early; - timingPoint = BeatSyncSource.ControlPoints?.TimingPointAt(currentTrackTime) ?? TimingControlPoint.DEFAULT; - effectPoint = BeatSyncSource.ControlPoints?.EffectPointAt(currentTrackTime) ?? EffectControlPoint.DEFAULT; + TimingPoint = BeatSyncSource.ControlPoints?.TimingPointAt(currentTrackTime) ?? TimingControlPoint.DEFAULT; + EffectPoint = BeatSyncSource.ControlPoints?.EffectPointAt(currentTrackTime) ?? EffectControlPoint.DEFAULT; } else { @@ -111,28 +118,28 @@ namespace osu.Game.Graphics.Containers // we still want to show an idle animation, so use this container's time instead. currentTrackTime = Clock.CurrentTime + EarlyActivationMilliseconds; - timingPoint = TimingControlPoint.DEFAULT; - effectPoint = EffectControlPoint.DEFAULT; + TimingPoint = TimingControlPoint.DEFAULT; + EffectPoint = EffectControlPoint.DEFAULT; } - double beatLength = timingPoint.BeatLength / Divisor; + double beatLength = TimingPoint.BeatLength / Divisor; while (beatLength < MinimumBeatLength) beatLength *= 2; - int beatIndex = (int)((currentTrackTime - timingPoint.Time) / beatLength) - (timingPoint.OmitFirstBarLine ? 1 : 0); + int beatIndex = (int)((currentTrackTime - TimingPoint.Time) / beatLength) - (TimingPoint.OmitFirstBarLine ? 1 : 0); // The beats before the start of the first control point are off by 1, this should do the trick - if (currentTrackTime < timingPoint.Time) + if (currentTrackTime < TimingPoint.Time) beatIndex--; - TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength; + TimeUntilNextBeat = (TimingPoint.Time - currentTrackTime) % beatLength; if (TimeUntilNextBeat <= 0) TimeUntilNextBeat += beatLength; TimeSinceLastBeat = beatLength - TimeUntilNextBeat; - if (ReferenceEquals(timingPoint, lastTimingPoint) && beatIndex == lastBeat) + if (ReferenceEquals(TimingPoint, lastTimingPoint) && beatIndex == lastBeat) return; // as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat. @@ -140,13 +147,13 @@ namespace osu.Game.Graphics.Containers if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE) { using (BeginDelayedSequence(-TimeSinceLastBeat)) - OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.CurrentAmplitudes); + OnNewBeat(beatIndex, TimingPoint, EffectPoint, BeatSyncSource.CurrentAmplitudes); } lastBeat = beatIndex; - lastTimingPoint = timingPoint; + lastTimingPoint = TimingPoint; - IsKiaiTime = effectPoint.KiaiMode; + IsKiaiTime = EffectPoint.KiaiMode; } } } diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index 2abdb508ae..65a00b725c 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -14,6 +14,8 @@ namespace osu.Game.Graphics.Containers /// public partial class ExpandingContainer : Container, IExpandingContainer { + public const double TRANSITION_DURATION = 500; + private readonly float contractedWidth; private readonly float expandedWidth; @@ -38,21 +40,24 @@ namespace osu.Game.Graphics.Containers RelativeSizeAxes = Axes.Y; Width = contractedWidth; - InternalChild = new OsuScrollContainer + InternalChild = CreateScrollContainer().With(s => { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = FillFlow = new FillFlowContainer + s.RelativeSizeAxes = Axes.Both; + s.ScrollbarVisible = false; + }).WithChild( + FillFlow = new FillFlowContainer { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - }, - }; + } + ); } + protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + private ScheduledDelegate? hoverExpandEvent; protected override void LoadComplete() @@ -61,7 +66,7 @@ namespace osu.Game.Graphics.Containers Expanded.BindValueChanged(v => { - this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, 500, Easing.OutQuint); + this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, TRANSITION_DURATION, Easing.OutQuint); }, true); } @@ -71,12 +76,6 @@ namespace osu.Game.Graphics.Containers return true; } - protected override bool OnMouseMove(MouseMoveEvent e) - { - updateHoverExpansion(); - return base.OnMouseMove(e); - } - protected override void OnHoverLost(HoverLostEvent e) { if (hoverExpandEvent != null) diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index aa72996fff..fd5ca39dac 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -134,9 +134,14 @@ namespace osu.Game.Graphics.Containers protected virtual DrawableLinkCompiler CreateLinkCompiler(ITextPart textPart) => new DrawableLinkCompiler(textPart); - // 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. - public override IEnumerable FlowingChildren => base.FlowingChildren.Where(c => !(c is DrawableLinkCompiler)); + protected override InnerFlow CreateFlow() => new LinkFlow(); + + private partial class LinkFlow : InnerFlow + { + // 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. + public override IEnumerable FlowingChildren => base.FlowingChildren.Where(c => !(c is DrawableLinkCompiler)); + } } } diff --git a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs index 13c672cbd6..694388b92c 100644 --- a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs +++ b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; @@ -32,18 +34,18 @@ namespace osu.Game.Graphics.Containers /// The instance of the logo to be used for tracking. /// The duration of the initial transform. Default is instant. /// The easing type of the initial transform. - public void StartTracking(OsuLogo logo, double duration = 0, Easing easing = Easing.None) + public IDisposable StartTracking(OsuLogo logo, double duration = 0, Easing easing = Easing.None) { + if (Logo != null && Logo != logo) + throw new InvalidOperationException("A different logo is already being tracked."); + ArgumentNullException.ThrowIfNull(logo); if (logo.IsTracking && Logo == null) throw new InvalidOperationException($"Cannot track an instance of {typeof(OsuLogo)} to multiple {typeof(LogoTrackingContainer)}s"); - if (Logo != logo && Logo != null) - { - // If we're replacing the logo to be tracked, the old one no longer has a tracking container - Logo.IsTracking = false; - } + if (logo.IsTracking) + throw new InvalidOperationException("A previous tracking operation is still active. Dispose of its return value before starting a new tracking operation."); Logo = logo; Logo.IsTracking = true; @@ -53,15 +55,13 @@ namespace osu.Game.Graphics.Containers startTime = null; startPosition = null; - } - /// - /// Stops the logo assigned in from tracking the facade's position. - /// - public void StopTracking() - { - if (Logo != null) + return new InvokeOnDisposal(stopTracking); + + void stopTracking() { + Debug.Assert(Logo != null); + Logo.IsTracking = false; Logo = null; } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs index 10207dd389..340e59dd91 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs @@ -13,7 +13,7 @@ namespace osu.Game.Graphics.Containers.Markdown public LocalisableString TooltipText { get; } public OsuMarkdownImage(LinkInline linkInline) - : base(linkInline.Url) + : base($"https://osu.ppy.sh/media-url?url={linkInline.Url}") { TooltipText = linkInline.Title; } diff --git a/osu.Game/Graphics/Containers/OsuClickableContainer.cs b/osu.Game/Graphics/Containers/OsuClickableContainer.cs index fceee90d06..dbc354ae07 100644 --- a/osu.Game/Graphics/Containers/OsuClickableContainer.cs +++ b/osu.Game/Graphics/Containers/OsuClickableContainer.cs @@ -18,6 +18,8 @@ namespace osu.Game.Graphics.Containers private readonly Container content = new Container { RelativeSizeAxes = Axes.Both }; + private HoverSounds samples = null!; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => // base call is checked for cases when `OsuClickableContainer` has masking applied to it directly (ie. externally in object initialisation). base.ReceivePositionalInputAt(screenSpacePos) @@ -33,6 +35,14 @@ namespace osu.Game.Graphics.Containers this.sampleSet = sampleSet; } + public void TriggerClickWithSound() + { + TriggerClick(); + + // TriggerClick doesn't recursively fire the event so we need to manually do this. + (samples as HoverClickSounds)?.PlayClickSample(); + } + public virtual LocalisableString TooltipText { get; set; } [BackgroundDependencyLoader] @@ -46,7 +56,7 @@ namespace osu.Game.Graphics.Containers AddRangeInternal(new Drawable[] { - CreateHoverSounds(sampleSet), + samples = CreateHoverSounds(sampleSet), content, }); } diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 1945b2f0dd..3c530a3ace 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -15,7 +15,6 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.Containers { - [Cached(typeof(IPreviewTrackOwner))] public abstract partial class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler { protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs index 3b5e48d23e..e396eb6ec9 100644 --- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs +++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs @@ -15,9 +15,11 @@ namespace osu.Game.Graphics.Containers { protected const float FADE_DURATION = 500; - protected Color4 HoverColour; + public Color4? HoverColour { get; set; } + private Color4 fallbackHoverColour; - protected Color4 IdleColour = Color4.White; + public Color4? IdleColour { get; set; } + private Color4 fallbackIdleColour; protected virtual IEnumerable EffectTargets => new[] { Content }; @@ -67,18 +69,18 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader] private void load(OsuColour colours) { - if (HoverColour == default) - HoverColour = colours.Yellow; + fallbackHoverColour = colours.Yellow; + fallbackIdleColour = Color4.White; } protected override void LoadComplete() { base.LoadComplete(); - EffectTargets.ForEach(d => d.FadeColour(IdleColour)); + EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour)); } - private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour, FADE_DURATION, Easing.OutQuint)); + private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour ?? fallbackHoverColour, FADE_DURATION, Easing.OutQuint)); - private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour, FADE_DURATION, Easing.OutQuint)); + private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour, FADE_DURATION, Easing.OutQuint)); } } diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index a3cd5a4902..43a42eae57 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -26,26 +26,12 @@ namespace osu.Game.Graphics.Containers } } - public partial class OsuScrollContainer : ScrollContainer where T : Drawable + public partial class OsuScrollContainer : ScrollContainer + where T : Drawable { public const float SCROLL_BAR_WIDTH = 10; public const float SCROLL_BAR_PADDING = 3; - /// - /// Allows controlling the scroll bar from any position in the container using the right mouse button. - /// Uses the value of to smoothly scroll to the dragged location. - /// - public bool RightMouseScrollbar; - - /// - /// Controls the rate with which the target position is approached when performing a relative drag. Default is 0.02. - /// - public double DistanceDecayOnRightMouseScrollbar = 0.02; - - private bool rightMouseDragging; - - protected override bool IsDragging => base.IsDragging || rightMouseDragging; - public OsuScrollContainer(Direction scrollDirection = Direction.Vertical) : base(scrollDirection) { @@ -59,11 +45,11 @@ namespace osu.Game.Graphics.Containers /// An added amount to scroll beyond the requirement to bring the target into view. public void ScrollIntoView(Drawable d, bool animated = true, float extraScroll = 0) { - float childPos0 = GetChildPosInContent(d); - float childPos1 = GetChildPosInContent(d, d.DrawSize); + double childPos0 = GetChildPosInContent(d); + double childPos1 = GetChildPosInContent(d, d.DrawSize); - float minPos = Math.Min(childPos0, childPos1); - float maxPos = Math.Max(childPos0, childPos1); + double minPos = Math.Min(childPos0, childPos1); + double maxPos = Math.Max(childPos0, childPos1); if (minPos < Current || (minPos > Current && d.DrawSize[ScrollDim] > DisplayableContent)) ScrollTo(minPos - extraScroll, animated); @@ -71,50 +57,6 @@ namespace osu.Game.Graphics.Containers ScrollTo(maxPos - DisplayableContent + extraScroll, animated); } - protected override bool OnMouseDown(MouseDownEvent e) - { - if (shouldPerformRightMouseScroll(e)) - { - ScrollFromMouseEvent(e); - return true; - } - - return base.OnMouseDown(e); - } - - protected override void OnDrag(DragEvent e) - { - if (rightMouseDragging) - { - ScrollFromMouseEvent(e); - return; - } - - base.OnDrag(e); - } - - protected override bool OnDragStart(DragStartEvent e) - { - if (shouldPerformRightMouseScroll(e)) - { - rightMouseDragging = true; - return true; - } - - return base.OnDragStart(e); - } - - protected override void OnDragEnd(DragEndEvent e) - { - if (rightMouseDragging) - { - rightMouseDragging = false; - return; - } - - base.OnDragEnd(e); - } - protected override bool OnScroll(ScrollEvent e) { // allow for controlling volume when alt is held. @@ -124,15 +66,22 @@ namespace osu.Game.Graphics.Containers return base.OnScroll(e); } - protected virtual void ScrollFromMouseEvent(MouseEvent e) + #region Absolute scrolling + + /// + /// Controls the rate with which the target position is approached when performing a relative drag. Default is 0.02. + /// + public double DistanceDecayOnAbsoluteScroll = 0.02; + + protected virtual void ScrollToAbsolutePosition(Vector2 screenSpacePosition) { - float fromScrollbarPosition = FromScrollbarPosition(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim]); + float fromScrollbarPosition = FromScrollbarPosition(ToLocalSpace(screenSpacePosition)[ScrollDim]); float scrollbarCentreOffset = FromScrollbarPosition(Scrollbar.DrawHeight) * 0.5f; - ScrollTo(Clamp(fromScrollbarPosition - scrollbarCentreOffset), true, DistanceDecayOnRightMouseScrollbar); + ScrollTo(Clamp(fromScrollbarPosition - scrollbarCentreOffset), true, DistanceDecayOnAbsoluteScroll); } - private bool shouldPerformRightMouseScroll(MouseButtonEvent e) => RightMouseScrollbar && e.Button == MouseButton.Right; + #endregion protected override ScrollbarContainer CreateScrollbar(Direction direction) => new OsuScrollbar(direction); diff --git a/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs b/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs index d3bbc2e80b..dcb7f8efdd 100644 --- a/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -14,15 +12,29 @@ namespace osu.Game.Graphics.Containers { public partial class OsuTextFlowContainer : TextFlowContainer { - public OsuTextFlowContainer(Action defaultCreationParameters = null) + public OsuTextFlowContainer(Action? defaultCreationParameters = null) : base(defaultCreationParameters) { } protected override SpriteText CreateSpriteText() => new OsuSpriteText(); - public ITextPart AddArbitraryDrawable(Drawable drawable) => AddPart(new TextPartManual(drawable.Yield())); + public ITextPart AddArbitraryDrawable(Drawable drawable) => AddPart(new TextPartManual(new ArbitraryDrawableWrapper(drawable).Yield())); - public ITextPart AddIcon(IconUsage icon, Action creationParameters = null) => AddText(icon.Icon.ToString(), creationParameters); + public ITextPart AddIcon(IconUsage icon, Action? creationParameters = null) => AddText(icon.Icon.ToString(), creationParameters); + + private partial class ArbitraryDrawableWrapper : Container, IHasLineBaseHeight + { + private readonly IHasLineBaseHeight? lineBaseHeightSource; + + public float LineBaseHeight => lineBaseHeightSource?.LineBaseHeight ?? DrawHeight; + + public ArbitraryDrawableWrapper(Drawable drawable) + { + Child = drawable; + lineBaseHeightSource = drawable as IHasLineBaseHeight; + AutoSizeAxes = Axes.Both; + } + } } } diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index c47aba2f0c..9d2a1c16af 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -26,17 +24,17 @@ namespace osu.Game.Graphics.Containers { internal const float TRANSITION_DURATION = 500; - private Bindable sizeX; - private Bindable sizeY; - private Bindable posX; - private Bindable posY; - private Bindable applySafeAreaPadding; + private Bindable sizeX = null!; + private Bindable sizeY = null!; + private Bindable posX = null!; + private Bindable posY = null!; + private Bindable applySafeAreaPadding = null!; - private Bindable safeAreaPadding; + private Bindable safeAreaPadding = null!; private readonly ScalingMode? targetMode; - private Bindable scalingMode; + private Bindable scalingMode = null!; private readonly Container content; protected override Container Content => content; @@ -45,9 +43,9 @@ namespace osu.Game.Graphics.Containers private readonly Container sizableContainer; - private BackgroundScreenStack backgroundStack; + private BackgroundScreenStack? backgroundStack; - private Bindable scalingMenuBackgroundDim; + private Bindable scalingMenuBackgroundDim = null!; private RectangleF? customRect; private bool customRectIsRelativePosition; @@ -88,7 +86,8 @@ namespace osu.Game.Graphics.Containers public partial class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer { private readonly bool applyUIScale; - private Bindable uiScale; + + private Bindable? uiScale; protected float CurrentScale { get; private set; } = 1; @@ -99,6 +98,9 @@ namespace osu.Game.Graphics.Containers this.applyUIScale = applyUIScale; } + [Resolved(canBeNull: true)] + private OsuGame? game { get; set; } + [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig) { @@ -111,6 +113,8 @@ namespace osu.Game.Graphics.Containers protected override void Update() { + if (game != null) + TargetDrawSize = game.ScalingContainerTargetDrawSize; Scale = new Vector2(CurrentScale); Size = new Vector2(1 / CurrentScale); @@ -233,13 +237,13 @@ namespace osu.Game.Graphics.Containers private partial class SizeableAlwaysInputContainer : Container { [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; [Resolved] - private ISafeArea safeArea { get; set; } + private ISafeArea safeArea { get; set; } = null!; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; private readonly bool confineHostCursor; private readonly LayoutValue cursorRectCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 9f41c4eff2..828fc9704c 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -208,7 +208,7 @@ namespace osu.Game.Graphics.Containers private float getScrollTargetForDrawable(Drawable target) { // implementation similar to ScrollIntoView but a bit more nuanced. - return scrollContainer.GetChildPosInContent(target) - scrollContainer.DisplayableContent * scroll_y_centre; + return (float)(scrollContainer.GetChildPosInContent(target) - scrollContainer.DisplayableContent * scroll_y_centre); } public void ScrollToTop() => scrollContainer.ScrollTo(0); @@ -259,7 +259,7 @@ namespace osu.Game.Graphics.Containers updateSectionsMargin(); } - float currentScroll = scrollContainer.Current; + float currentScroll = (float)scrollContainer.Current; if (currentScroll != lastKnownScroll) { diff --git a/osu.Game/Graphics/Containers/ShearAligningWrapper.cs b/osu.Game/Graphics/Containers/ShearAligningWrapper.cs new file mode 100644 index 0000000000..542f269f93 --- /dev/null +++ b/osu.Game/Graphics/Containers/ShearAligningWrapper.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Graphics.Containers +{ + /// + /// Adds left padding based on direct parent to make sheared pieces in a vertical flow aligned appropriately. + /// + /// + /// See associated test scene for further demonstration. + /// + public partial class ShearAligningWrapper : CompositeDrawable + { + private readonly LayoutValue layout = new LayoutValue(Invalidation.MiscGeometry); + + // Sheared components regularly end up off the side of the screen due to padding considerations. + // If we use this class in places where performance is important, we should reconsider the handling of this. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + + public ShearAligningWrapper(Drawable drawable) + { + RelativeSizeAxes = drawable.RelativeSizeAxes; + AutoSizeAxes = Axes.Both & ~drawable.RelativeSizeAxes; + + InternalChild = drawable; + + AddLayout(layout); + } + + protected override void Update() + { + base.Update(); + + if (!layout.IsValid) + { + updateLayout(); + layout.Validate(); + } + } + + private void updateLayout() + { + float shearWidth = OsuGame.SHEAR.X * Parent!.DrawHeight; + float relativeY = Parent!.DrawHeight == 0 ? 0 : InternalChild.ToSpaceOfOtherDrawable(Vector2.Zero, Parent).Y / Parent!.DrawHeight; + Padding = new MarginPadding { Left = shearWidth * relativeY }; + } + } +} diff --git a/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs index f263ae36cb..7845054178 100644 --- a/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs +++ b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Extensions.MatrixExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; @@ -54,7 +55,8 @@ namespace osu.Game.Graphics.Containers parentMatrix.M31 = 0.0f; parentMatrix.M32 = 0.0f; - Matrix3 reversedParent = parentMatrix.Inverted(); + Matrix3 reversedParent = parentMatrix; + MatrixExtensions.FastInvert(ref reversedParent); // Extract the rotation. float angle = MathF.Atan2(reversedParent.M12, reversedParent.M11); diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs index 354a57b7d2..ab17c3f9e3 100644 --- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Input.Events; +using osuTK; namespace osu.Game.Graphics.Containers { @@ -35,7 +35,7 @@ namespace osu.Game.Graphics.Containers { } - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default) { UserScrolling = true; base.OnUserScroll(value, animated, distanceDecay); @@ -47,13 +47,13 @@ namespace osu.Game.Graphics.Containers base.ScrollIntoView(target, animated); } - protected override void ScrollFromMouseEvent(MouseEvent e) + protected override void ScrollToAbsolutePosition(Vector2 screenSpacePosition) { UserScrolling = true; - base.ScrollFromMouseEvent(e); + base.ScrollToAbsolutePosition(screenSpacePosition); } - public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + public new void ScrollTo(double value, bool animated = true, double? distanceDecay = null) { UserScrolling = false; base.ScrollTo(value, animated, distanceDecay); diff --git a/osu.Game/Graphics/DrawableDate.cs b/osu.Game/Graphics/DrawableDate.cs index 0e5bcc8019..e5383bf3a9 100644 --- a/osu.Game/Graphics/DrawableDate.cs +++ b/osu.Game/Graphics/DrawableDate.cs @@ -5,6 +5,8 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Utils; @@ -71,12 +73,39 @@ namespace osu.Game.Graphics Scheduler.AddDelayed(updateTimeWithReschedule, timeUntilNextUpdate); } - protected virtual string Format() => HumanizerUtils.Humanize(Date); + protected virtual LocalisableString Format() => new LocalisableString(new HumanisedDate(Date)); private void updateTime() => Text = Format(); public ITooltip GetCustomTooltip() => new DateTooltip(); public DateTimeOffset TooltipContent => Date; + + private class HumanisedDate : ILocalisableStringData + { + public readonly DateTimeOffset Date; + + public HumanisedDate(DateTimeOffset date) + { + Date = date; + } + + /// + /// Humanizer formats the relative to the local computer time. + /// Therefore, replacing a instance with another instance of the class with the same + /// should have the effect of replacing and re-formatting the text. + /// Including in equality members would stop this from happening, as + /// has equality-based early guards to prevent redundant text replaces. + /// Thus, instances of these class just compare to any to ensure re-formatting happens correctly. + /// There are "technically" more "correct" ways to do this (like also including the current time into equality checks), + /// but they are simultaneously functionally equivalent to this and overly convoluted. + /// This is a private hack-job of a wrapper around humanizer anyway. + /// + public bool Equals(ILocalisableStringData? other) => false; + + public string GetLocalised(LocalisationParameters parameters) => HumanizerUtils.Humanize(Date); + + public override string ToString() => GetLocalised(LocalisationParameters.DEFAULT); + } } } diff --git a/osu.Game/Graphics/GhostIcon.cs b/osu.Game/Graphics/GhostIcon.cs new file mode 100644 index 0000000000..9ff036adf0 --- /dev/null +++ b/osu.Game/Graphics/GhostIcon.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.Runtime.InteropServices; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Shaders.Types; +using osu.Framework.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics +{ + /// + /// A (very cute) animated version of the icon. + /// + public partial class GhostIcon : Drawable + { + private IShader ghostShader = null!; + + /// + /// How long one complete loop of the ghost's animation takes, in milliseconds + /// + public float AnimationDuration = 2000; + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + ghostShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "Ghost"); + } + + protected override void Update() + { + base.Update(); + + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new GhostIconDrawNode(this); + + private class GhostIconDrawNode : DrawNode + { + protected new GhostIcon Source => (GhostIcon)base.Source; + + public GhostIconDrawNode(IDrawable source) + : base(source) + { + } + + private Quad screenSpaceDrawQuad; + private Vector4 drawRectangle; + private Vector2 blend; + private IShader shader = null!; + private float time; + + public override void ApplyState() + { + base.ApplyState(); + + screenSpaceDrawQuad = Source.ScreenSpaceDrawQuad; + drawRectangle = new Vector4(0, 0, Source.DrawWidth, Source.DrawHeight); + shader = Source.ghostShader; + blend = new Vector2(Math.Min(Source.DrawWidth, Source.DrawHeight) / Math.Min(screenSpaceDrawQuad.Width, screenSpaceDrawQuad.Height)); + time = (float)(Source.Time.Current / Source.AnimationDuration) % 1f; + } + + private IUniformBuffer? ghostParametersBuffer; + + private IVertexBatch? quadBatch; + + protected override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + if (!renderer.BindTexture(renderer.WhitePixel)) + return; + + quadBatch ??= renderer.CreateQuadBatch(1, 2); + ghostParametersBuffer ??= renderer.CreateUniformBuffer(); + + ghostParametersBuffer.Data = new GhostParameters + { + Time = time + }; + + shader.Bind(); + shader.BindUniformBlock("m_GhostParameters", ghostParametersBuffer); + + var vertexAction = quadBatch.AddAction; + + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.BottomLeft, + TexturePosition = new Vector2(0, 1), + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.BottomLeft.SRGB, + }); + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.BottomRight, + TexturePosition = new Vector2(1, 1), + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.BottomRight.SRGB, + }); + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.TopRight, + TexturePosition = new Vector2(1, 0), + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.TopRight.SRGB, + }); + vertexAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.TopLeft, + TexturePosition = Vector2.Zero, + TextureRect = drawRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.TopLeft.SRGB, + }); + + shader.Unbind(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct GhostParameters + { + public UniformFloat Time; + private UniformPadding12 pad; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + ghostParametersBuffer?.Dispose(); + quadBatch?.Dispose(); + } + } + } +} diff --git a/osu.Game/Graphics/InputBlockingContainer.cs b/osu.Game/Graphics/InputBlockingContainer.cs index f652dc8850..dedf328642 100644 --- a/osu.Game/Graphics/InputBlockingContainer.cs +++ b/osu.Game/Graphics/InputBlockingContainer.cs @@ -8,6 +8,8 @@ namespace osu.Game.Graphics { /// /// A simple container which blocks input events from travelling through it. + /// + /// Note that this will block right clicks as well. Special care needs to be taken to not break context menus from displaying. /// public partial class InputBlockingContainer : Container { diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index c479d0cfe4..0eca359060 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -21,9 +21,11 @@ namespace osu.Game.Graphics public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255); /// - /// Retrieves the colour for a given point in the star range. + /// The maximum star rating colour which can be distinguished against a black background. /// - public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(new[] + public const float STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF = 6.5f; + + public static readonly (float, Color4)[] STAR_DIFFICULTY_SPECTRUM = { (0.1f, Color4Extensions.FromHex("aaaaaa")), (0.1f, Color4Extensions.FromHex("4290fb")), @@ -37,7 +39,13 @@ namespace osu.Game.Graphics (6.7f, Color4Extensions.FromHex("6563de")), (7.7f, Color4Extensions.FromHex("18158e")), (9.0f, Color4.Black), - }, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); + (10.0f, Color4.Black), + }; + + /// + /// Retrieves the colour for a given point in the star range. + /// + public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(STAR_DIFFICULTY_SPECTRUM, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); /// /// Retrieves the colour for a . @@ -120,6 +128,9 @@ namespace osu.Game.Graphics { switch (status) { + case BeatmapOnlineStatus.None: + return Color4.RosyBrown; + case BeatmapOnlineStatus.LocallyModified: return Color4.OrangeRed; @@ -195,11 +206,32 @@ namespace osu.Game.Graphics } } + /// + /// Retrieves the accent colour representing a 's current status. + /// + public Color4 ForRoomStatus(Room room) + { + if (room.HasEnded) + return YellowDarker; + + switch (room.Status) + { + case RoomStatus.Playing: + return Purple; + + default: + if (room.HasPassword) + return GreenDark; + + return GreenLight; + } + } + /// /// Retrieves colour for a . /// See https://www.figma.com/file/YHWhp9wZ089YXgB7pe6L1k/Tier-Colours /// - public ColourInfo ForRankingTier(RankingTier tier) + public static ColourInfo ForRankingTier(RankingTier tier) { switch (tier) { @@ -382,6 +414,12 @@ namespace osu.Game.Graphics public readonly Color4 Orange3 = Color4Extensions.FromHex(@"cca633"); public readonly Color4 Orange4 = Color4Extensions.FromHex(@"6b5c2e"); + public readonly Color4 DarkOrange0 = Color4Extensions.FromHex(@"ffbb99"); + public readonly Color4 DarkOrange1 = Color4Extensions.FromHex(@"ff9966"); + public readonly Color4 DarkOrange2 = Color4Extensions.FromHex(@"eb7e47"); + public readonly Color4 DarkOrange3 = Color4Extensions.FromHex(@"cc6633"); + public readonly Color4 DarkOrange4 = Color4Extensions.FromHex(@"6b422e"); + public readonly Color4 Red0 = Color4Extensions.FromHex(@"ff9b9b"); public readonly Color4 Red1 = Color4Extensions.FromHex(@"ff6666"); public readonly Color4 Red2 = Color4Extensions.FromHex(@"eb4747"); diff --git a/osu.Game/Graphics/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index 7aa98ece95..b314c602f5 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -15,15 +15,65 @@ namespace osu.Game.Graphics /// public const float DEFAULT_FONT_SIZE = 16; + /// + /// Template font styles which should be preferred whenever possible for UI elements. + /// + public static class Style + { + /// + /// Equivalent to Torus with 32px size and semi-bold weight. + /// + public static FontUsage Title => GetFont(Typeface.TorusAlternate, size: 32, weight: FontWeight.Regular); + + /// + /// Torus with 28px size and semi-bold weight. + /// + public static FontUsage Subtitle => GetFont(size: 28, weight: FontWeight.Regular); + + /// + /// Torus with 22px size and bold weight. + /// + public static FontUsage Heading1 => GetFont(size: 22, weight: FontWeight.Bold); + + /// + /// Torus with 18px size and semi-bold weight. + /// + public static FontUsage Heading2 => GetFont(size: 18, weight: FontWeight.SemiBold); + + /// + /// Torus with 16px size and regular weight. + /// + public static FontUsage Body => GetFont(size: DEFAULT_FONT_SIZE, weight: FontWeight.Regular); + + /// + /// Torus with 14px size and regular weight. + /// + public static FontUsage Caption1 => GetFont(size: 14, weight: FontWeight.Regular); + + /// + /// Torus with 12px size and regular weight. + /// + public static FontUsage Caption2 => GetFont(size: 12, weight: FontWeight.Regular); + } + /// /// The default font. /// - public static FontUsage Default => GetFont(); + public static FontUsage Default => GetFont(weight: FontWeight.Medium); + /// + /// Font face for numeric display. + /// public static FontUsage Numeric => GetFont(Typeface.Venera, weight: FontWeight.Bold); + /// + /// Default font face for UI and game elements. + /// public static FontUsage Torus => GetFont(Typeface.Torus, weight: FontWeight.Regular); + /// + /// Default font face with alternate character set for headings and flair text. + /// public static FontUsage TorusAlternate => GetFont(Typeface.TorusAlternate, weight: FontWeight.Regular); public static FontUsage Inter => GetFont(Typeface.Inter, weight: FontWeight.Regular); diff --git a/osu.Game/Graphics/OsuIcon.cs b/osu.Game/Graphics/OsuIcon.cs index 9879ef5d14..0cf2acadda 100644 --- a/osu.Game/Graphics/OsuIcon.cs +++ b/osu.Game/Graphics/OsuIcon.cs @@ -16,98 +16,19 @@ namespace osu.Game.Graphics { public static class OsuIcon { - #region Legacy spritesheet-based icons - - private static IconUsage get(int icon) => new IconUsage((char)icon, @"osuFont"); - - // ruleset icons in circles - public static IconUsage RulesetOsu => get(0xe000); - public static IconUsage RulesetMania => get(0xe001); - public static IconUsage RulesetCatch => get(0xe002); - public static IconUsage RulesetTaiko => get(0xe003); - - // ruleset icons without circles - public static IconUsage FilledCircle => get(0xe004); - public static IconUsage Logo => get(0xe006); - public static IconUsage ChevronDownCircle => get(0xe007); - public static IconUsage EditCircle => get(0xe033); - public static IconUsage LeftCircle => get(0xe034); - public static IconUsage RightCircle => get(0xe035); - public static IconUsage Charts => get(0xe036); - public static IconUsage Solo => get(0xe037); - public static IconUsage Multi => get(0xe038); - public static IconUsage Gear => get(0xe039); - - // misc icons - public static IconUsage Bat => get(0xe008); - public static IconUsage Bubble => get(0xe009); - public static IconUsage BubblePop => get(0xe02e); - public static IconUsage Dice => get(0xe011); - public static IconUsage HeartBreak => get(0xe030); - public static IconUsage Hot => get(0xe031); - public static IconUsage ListSearch => get(0xe032); - - //osu! playstyles - public static IconUsage PlayStyleTablet => get(0xe02a); - public static IconUsage PlayStyleMouse => get(0xe029); - public static IconUsage PlayStyleKeyboard => get(0xe02b); - public static IconUsage PlayStyleTouch => get(0xe02c); - - // osu! difficulties - public static IconUsage EasyOsu => get(0xe015); - public static IconUsage NormalOsu => get(0xe016); - public static IconUsage HardOsu => get(0xe017); - public static IconUsage InsaneOsu => get(0xe018); - public static IconUsage ExpertOsu => get(0xe019); - - // taiko difficulties - public static IconUsage EasyTaiko => get(0xe01a); - public static IconUsage NormalTaiko => get(0xe01b); - public static IconUsage HardTaiko => get(0xe01c); - public static IconUsage InsaneTaiko => get(0xe01d); - public static IconUsage ExpertTaiko => get(0xe01e); - - // fruits difficulties - public static IconUsage EasyFruits => get(0xe01f); - public static IconUsage NormalFruits => get(0xe020); - public static IconUsage HardFruits => get(0xe021); - public static IconUsage InsaneFruits => get(0xe022); - public static IconUsage ExpertFruits => get(0xe023); - - // mania difficulties - public static IconUsage EasyMania => get(0xe024); - public static IconUsage NormalMania => get(0xe025); - public static IconUsage HardMania => get(0xe026); - public static IconUsage InsaneMania => get(0xe027); - public static IconUsage ExpertMania => get(0xe028); - - // mod icons - public static IconUsage ModPerfect => get(0xe049); - public static IconUsage ModAutopilot => get(0xe03a); - public static IconUsage ModAuto => get(0xe03b); - public static IconUsage ModCinema => get(0xe03c); - public static IconUsage ModDoubleTime => get(0xe03d); - public static IconUsage ModEasy => get(0xe03e); - public static IconUsage ModFlashlight => get(0xe03f); - public static IconUsage ModHalftime => get(0xe040); - public static IconUsage ModHardRock => get(0xe041); - public static IconUsage ModHidden => get(0xe042); - public static IconUsage ModNightcore => get(0xe043); - public static IconUsage ModNoFail => get(0xe044); - public static IconUsage ModRelax => get(0xe045); - public static IconUsage ModSpunOut => get(0xe046); - public static IconUsage ModSuddenDeath => get(0xe047); - public static IconUsage ModTarget => get(0xe048); - - // Use "Icons/BeatmapDetails/mod-icon" instead - // public static IconUsage ModBg => Get(0xe04a); - - #endregion - - #region New single-file-based icons - public const string FONT_NAME = @"Icons"; + // ruleset icons + public static IconUsage RulesetOsu => get(OsuIconMapping.RulesetOsu); + public static IconUsage RulesetMania => get(OsuIconMapping.RulesetMania); + public static IconUsage RulesetCatch => get(OsuIconMapping.RulesetCatch); + public static IconUsage RulesetTaiko => get(OsuIconMapping.RulesetTaiko); + + public static IconUsage Logo => get(OsuIconMapping.Logo); + public static IconUsage EditCircle => get(OsuIconMapping.EditCircle); + public static IconUsage LeftCircle => get(OsuIconMapping.LeftCircle); + public static IconUsage RightCircle => get(OsuIconMapping.RightCircle); + public static IconUsage Audio => get(OsuIconMapping.Audio); public static IconUsage Beatmap => get(OsuIconMapping.Beatmap); public static IconUsage Calendar => get(OsuIconMapping.Calendar); @@ -115,6 +36,7 @@ namespace osu.Game.Graphics public static IconUsage ChangelogB => get(OsuIconMapping.ChangelogB); public static IconUsage Chat => get(OsuIconMapping.Chat); public static IconUsage CheckCircle => get(OsuIconMapping.CheckCircle); + public static IconUsage Clock => get(OsuIconMapping.Clock); public static IconUsage CollapseA => get(OsuIconMapping.CollapseA); public static IconUsage Collections => get(OsuIconMapping.Collections); public static IconUsage Cross => get(OsuIconMapping.Cross); @@ -141,6 +63,7 @@ namespace osu.Game.Graphics public static IconUsage Input => get(OsuIconMapping.Input); public static IconUsage Maintenance => get(OsuIconMapping.Maintenance); public static IconUsage Megaphone => get(OsuIconMapping.Megaphone); + public static IconUsage Metronome => get(OsuIconMapping.Metronome); public static IconUsage Music => get(OsuIconMapping.Music); public static IconUsage News => get(OsuIconMapping.News); public static IconUsage Next => get(OsuIconMapping.Next); @@ -179,10 +102,116 @@ namespace osu.Game.Graphics public static IconUsage Tortoise => get(OsuIconMapping.Tortoise); public static IconUsage Hare => get(OsuIconMapping.Hare); + // mod icons + + public static IconUsage ModNoMod => get(OsuIconMapping.ModNoMod); + + /* + can be regenerated semi-automatically using osu-web's mod database via + + $ jq -r '.[].Mods[].Name' mods.json | sort | uniq | \ + sed 's/ //g' | \ + awk '{print "public static IconUsage Mod" $0 " => get(OsuIconMapping.Mod" $0 ");"}' | pbcopy + */ + + public static IconUsage ModAccuracyChallenge => get(OsuIconMapping.ModAccuracyChallenge); + public static IconUsage ModAdaptiveSpeed => get(OsuIconMapping.ModAdaptiveSpeed); + public static IconUsage ModAlternate => get(OsuIconMapping.ModAlternate); + public static IconUsage ModApproachDifferent => get(OsuIconMapping.ModApproachDifferent); + public static IconUsage ModAutopilot => get(OsuIconMapping.ModAutopilot); + public static IconUsage ModAutoplay => get(OsuIconMapping.ModAutoplay); + public static IconUsage ModBarrelRoll => get(OsuIconMapping.ModBarrelRoll); + public static IconUsage ModBlinds => get(OsuIconMapping.ModBlinds); + public static IconUsage ModBloom => get(OsuIconMapping.ModBloom); + public static IconUsage ModBubbles => get(OsuIconMapping.ModBubbles); + public static IconUsage ModCinema => get(OsuIconMapping.ModCinema); + public static IconUsage ModClassic => get(OsuIconMapping.ModClassic); + public static IconUsage ModConstantSpeed => get(OsuIconMapping.ModConstantSpeed); + public static IconUsage ModCover => get(OsuIconMapping.ModCover); + public static IconUsage ModDaycore => get(OsuIconMapping.ModDaycore); + public static IconUsage ModDeflate => get(OsuIconMapping.ModDeflate); + public static IconUsage ModDepth => get(OsuIconMapping.ModDepth); + public static IconUsage ModDifficultyAdjust => get(OsuIconMapping.ModDifficultyAdjust); + public static IconUsage ModDoubleTime => get(OsuIconMapping.ModDoubleTime); + public static IconUsage ModDualStages => get(OsuIconMapping.ModDualStages); + public static IconUsage ModEasy => get(OsuIconMapping.ModEasy); + public static IconUsage ModEightKeys => get(OsuIconMapping.ModEightKeys); + public static IconUsage ModFadeIn => get(OsuIconMapping.ModFadeIn); + public static IconUsage ModFiveKeys => get(OsuIconMapping.ModFiveKeys); + public static IconUsage ModFlashlight => get(OsuIconMapping.ModFlashlight); + public static IconUsage ModFloatingFruits => get(OsuIconMapping.ModFloatingFruits); + public static IconUsage ModFourKeys => get(OsuIconMapping.ModFourKeys); + public static IconUsage ModFreezeFrame => get(OsuIconMapping.ModFreezeFrame); + public static IconUsage ModGrow => get(OsuIconMapping.ModGrow); + public static IconUsage ModHalfTime => get(OsuIconMapping.ModHalfTime); + public static IconUsage ModHardRock => get(OsuIconMapping.ModHardRock); + public static IconUsage ModHidden => get(OsuIconMapping.ModHidden); + public static IconUsage ModHoldOff => get(OsuIconMapping.ModHoldOff); + public static IconUsage ModInvert => get(OsuIconMapping.ModInvert); + public static IconUsage ModMagnetised => get(OsuIconMapping.ModMagnetised); + public static IconUsage ModMirror => get(OsuIconMapping.ModMirror); + public static IconUsage ModMovingFast => get(OsuIconMapping.ModMovingFast); + public static IconUsage ModMuted => get(OsuIconMapping.ModMuted); + public static IconUsage ModNightcore => get(OsuIconMapping.ModNightcore); + public static IconUsage ModNineKeys => get(OsuIconMapping.ModNineKeys); + public static IconUsage ModNoFail => get(OsuIconMapping.ModNoFail); + public static IconUsage ModNoRelease => get(OsuIconMapping.ModNoRelease); + public static IconUsage ModNoScope => get(OsuIconMapping.ModNoScope); + public static IconUsage ModOneKey => get(OsuIconMapping.ModOneKey); + public static IconUsage ModPerfect => get(OsuIconMapping.ModPerfect); + public static IconUsage ModRandom => get(OsuIconMapping.ModRandom); + public static IconUsage ModRelax => get(OsuIconMapping.ModRelax); + public static IconUsage ModRepel => get(OsuIconMapping.ModRepel); + public static IconUsage ModScoreV2 => get(OsuIconMapping.ModScoreV2); + public static IconUsage ModSevenKeys => get(OsuIconMapping.ModSevenKeys); + public static IconUsage ModSimplifiedRhythm => get(OsuIconMapping.ModSimplifiedRhythm); + public static IconUsage ModSingleTap => get(OsuIconMapping.ModSingleTap); + public static IconUsage ModSixKeys => get(OsuIconMapping.ModSixKeys); + public static IconUsage ModSpinIn => get(OsuIconMapping.ModSpinIn); + public static IconUsage ModSpunOut => get(OsuIconMapping.ModSpunOut); + public static IconUsage ModStrictTracking => get(OsuIconMapping.ModStrictTracking); + public static IconUsage ModSuddenDeath => get(OsuIconMapping.ModSuddenDeath); + public static IconUsage ModSwap => get(OsuIconMapping.ModSwap); + public static IconUsage ModSynesthesia => get(OsuIconMapping.ModSynesthesia); + public static IconUsage ModTargetPractice => get(OsuIconMapping.ModTargetPractice); + public static IconUsage ModTenKeys => get(OsuIconMapping.ModTenKeys); + public static IconUsage ModThreeKeys => get(OsuIconMapping.ModThreeKeys); + public static IconUsage ModTouchDevice => get(OsuIconMapping.ModTouchDevice); + public static IconUsage ModTraceable => get(OsuIconMapping.ModTraceable); + public static IconUsage ModTransform => get(OsuIconMapping.ModTransform); + public static IconUsage ModTwoKeys => get(OsuIconMapping.ModTwoKeys); + public static IconUsage ModWiggle => get(OsuIconMapping.ModWiggle); + public static IconUsage ModWindDown => get(OsuIconMapping.ModWindDown); + public static IconUsage ModWindUp => get(OsuIconMapping.ModWindUp); + private static IconUsage get(OsuIconMapping glyph) => new IconUsage((char)glyph, FONT_NAME); private enum OsuIconMapping { + [Description(@"Logo")] + Logo, + + [Description(@"RulesetOsu")] + RulesetOsu, + + [Description(@"RulesetMania")] + RulesetMania, + + [Description(@"RulesetCatch")] + RulesetCatch, + + [Description(@"RulesetTaiko")] + RulesetTaiko, + + [Description(@"EditCircle")] + EditCircle, + + [Description(@"LeftCircle")] + LeftCircle, + + [Description(@"RightCircle")] + RightCircle, + [Description(@"audio")] Audio, @@ -204,6 +233,9 @@ namespace osu.Game.Graphics [Description(@"check-circle")] CheckCircle, + [Description(@"clock")] + Clock, + [Description(@"collapse-a")] CollapseA, @@ -282,6 +314,9 @@ namespace osu.Game.Graphics [Description(@"megaphone")] Megaphone, + [Description(@"metronome")] + Metronome, + [Description(@"music")] Music, @@ -392,6 +427,224 @@ namespace osu.Game.Graphics [Description(@"hare")] Hare, + + // mod icons + + [Description(@"Mods/mod-no-mod")] + ModNoMod, + + /* + rest can be regenerated semi-automatically using osu-web's mod database via + $ jq -r '.[].Mods[].Name' mods.json | sort | uniq | \ + awk '{kebab = $0; gsub(" ", "-", kebab); pascal = $0; gsub(" ", "", pascal); print "[Description(@\"Mods/mod-" tolower(kebab) "\")]\nMod" pascal ",\n" }' | pbcopy + */ + + [Description(@"Mods/mod-accuracy-challenge")] + ModAccuracyChallenge, + + [Description(@"Mods/mod-adaptive-speed")] + ModAdaptiveSpeed, + + [Description(@"Mods/mod-alternate")] + ModAlternate, + + [Description(@"Mods/mod-approach-different")] + ModApproachDifferent, + + [Description(@"Mods/mod-autopilot")] + ModAutopilot, + + [Description(@"Mods/mod-autoplay")] + ModAutoplay, + + [Description(@"Mods/mod-barrel-roll")] + ModBarrelRoll, + + [Description(@"Mods/mod-blinds")] + ModBlinds, + + [Description(@"Mods/mod-bloom")] + ModBloom, + + [Description(@"Mods/mod-bubbles")] + ModBubbles, + + [Description(@"Mods/mod-cinema")] + ModCinema, + + [Description(@"Mods/mod-classic")] + ModClassic, + + [Description(@"Mods/mod-constant-speed")] + ModConstantSpeed, + + [Description(@"Mods/mod-cover")] + ModCover, + + [Description(@"Mods/mod-daycore")] + ModDaycore, + + [Description(@"Mods/mod-deflate")] + ModDeflate, + + [Description(@"Mods/mod-depth")] + ModDepth, + + [Description(@"Mods/mod-difficulty-adjust")] + ModDifficultyAdjust, + + [Description(@"Mods/mod-double-time")] + ModDoubleTime, + + [Description(@"Mods/mod-dual-stages")] + ModDualStages, + + [Description(@"Mods/mod-easy")] + ModEasy, + + [Description(@"Mods/mod-eight-keys")] + ModEightKeys, + + [Description(@"Mods/mod-fade-in")] + ModFadeIn, + + [Description(@"Mods/mod-five-keys")] + ModFiveKeys, + + [Description(@"Mods/mod-flashlight")] + ModFlashlight, + + [Description(@"Mods/mod-floating-fruits")] + ModFloatingFruits, + + [Description(@"Mods/mod-four-keys")] + ModFourKeys, + + [Description(@"Mods/mod-freeze-frame")] + ModFreezeFrame, + + [Description(@"Mods/mod-grow")] + ModGrow, + + [Description(@"Mods/mod-half-time")] + ModHalfTime, + + [Description(@"Mods/mod-hard-rock")] + ModHardRock, + + [Description(@"Mods/mod-hidden")] + ModHidden, + + [Description(@"Mods/mod-hold-off")] + ModHoldOff, + + [Description(@"Mods/mod-invert")] + ModInvert, + + [Description(@"Mods/mod-magnetised")] + ModMagnetised, + + [Description(@"Mods/mod-mirror")] + ModMirror, + + [Description(@"Mods/mod-moving-fast")] + ModMovingFast, + + [Description(@"Mods/mod-muted")] + ModMuted, + + [Description(@"Mods/mod-nightcore")] + ModNightcore, + + [Description(@"Mods/mod-nine-keys")] + ModNineKeys, + + [Description(@"Mods/mod-no-fail")] + ModNoFail, + + [Description(@"Mods/mod-no-release")] + ModNoRelease, + + [Description(@"Mods/mod-no-scope")] + ModNoScope, + + [Description(@"Mods/mod-one-key")] + ModOneKey, + + [Description(@"Mods/mod-perfect")] + ModPerfect, + + [Description(@"Mods/mod-random")] + ModRandom, + + [Description(@"Mods/mod-relax")] + ModRelax, + + [Description(@"Mods/mod-repel")] + ModRepel, + + [Description(@"Mods/mod-score-v2")] + ModScoreV2, + + [Description(@"Mods/mod-seven-keys")] + ModSevenKeys, + + [Description(@"Mods/mod-simplified-rhythm")] + ModSimplifiedRhythm, + + [Description(@"Mods/mod-single-tap")] + ModSingleTap, + + [Description(@"Mods/mod-six-keys")] + ModSixKeys, + + [Description(@"Mods/mod-spin-in")] + ModSpinIn, + + [Description(@"Mods/mod-spun-out")] + ModSpunOut, + + [Description(@"Mods/mod-strict-tracking")] + ModStrictTracking, + + [Description(@"Mods/mod-sudden-death")] + ModSuddenDeath, + + [Description(@"Mods/mod-swap")] + ModSwap, + + [Description(@"Mods/mod-synesthesia")] + ModSynesthesia, + + [Description(@"Mods/mod-target-practice")] + ModTargetPractice, + + [Description(@"Mods/mod-ten-keys")] + ModTenKeys, + + [Description(@"Mods/mod-three-keys")] + ModThreeKeys, + + [Description(@"Mods/mod-touch-device")] + ModTouchDevice, + + [Description(@"Mods/mod-traceable")] + ModTraceable, + + [Description(@"Mods/mod-transform")] + ModTransform, + + [Description(@"Mods/mod-two-keys")] + ModTwoKeys, + + [Description(@"Mods/mod-wiggle")] + ModWiggle, + + [Description(@"Mods/mod-wind-down")] + ModWindDown, + + [Description(@"Mods/mod-wind-up")] + ModWindUp, } public class OsuIconStore : ITextureStore, ITexturedGlyphLookupStore @@ -450,7 +703,5 @@ namespace osu.Game.Graphics textures.Dispose(); } } - - #endregion } } diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index 29bac8fbae..a85a1fc926 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,11 +12,11 @@ namespace osu.Game.Graphics.UserInterface // todo: remove this once all screens migrate to display the new game footer and back button. public partial class BackButton : VisibilityContainer { - public Action Action; + public Action? Action { get; init; } private readonly TwoLayerButton button; - public BackButton(ScreenFooter.BackReceptor receptor = null) + public BackButton(ScreenFooter.BackReceptor? receptor = null) { Size = TwoLayerButton.SIZE_EXTENDED; diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index c39f41bf72..423d9637b8 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -129,7 +129,7 @@ namespace osu.Game.Graphics.UserInterface Radius = 5, }, Colour = ButtonColour, - Shear = new Vector2(0.2f, 0), + Shear = OsuGame.SHEAR, Children = new Drawable[] { new Box @@ -149,7 +149,7 @@ namespace osu.Game.Graphics.UserInterface RelativeSizeAxes = Axes.Both, TriangleScale = 4, ColourDark = OsuColour.Gray(0.88f), - Shear = new Vector2(-0.2f, 0), + Shear = -OsuGame.SHEAR, ClampAxes = Axes.Y }, }, diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index b3ffd15816..e5a4e807b5 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -89,7 +89,7 @@ namespace osu.Game.Graphics.UserInterface { if (Link == null) return; - game?.CopyUrlToClipboard(Link); + game?.CopyToClipboard(Link); } } } diff --git a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs index 17e7be1d8b..e64a4c6c07 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs @@ -44,7 +44,8 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.Both, TextAnchor = Anchor.TopRight, Margin = new MarginPadding { Left = 5, Vertical = 10 }, - Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)) + Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)), + ParagraphSpacing = 0, }, textFlow = new OsuTextFlowContainer(cp => { @@ -56,6 +57,7 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Left = 35, Right = 10, Vertical = 10 }, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopRight, + ParagraphSpacing = 0, }, }; } diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index 884834ebe8..fea33bfa9d 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -42,18 +42,20 @@ namespace osu.Game.Graphics.UserInterface this.buttons = buttons ?? new[] { MouseButton.Left }; } + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleClick = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select") + ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); + + sampleClickDisabled = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select-disabled") + ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select-disabled"); + } + protected override bool OnClick(ClickEvent e) { if (buttons.Contains(e.Button)) - { - var channel = Enabled.Value ? sampleClick?.GetChannel() : sampleClickDisabled?.GetChannel(); - - if (channel != null) - { - channel.Frequency.Value = 0.99 + RNG.NextDouble(0.02); - channel.Play(); - } - } + PlayClickSample(); return base.OnClick(e); } @@ -66,14 +68,15 @@ namespace osu.Game.Graphics.UserInterface base.PlayHoverSample(); } - [BackgroundDependencyLoader] - private void load(AudioManager audio) + public void PlayClickSample() { - sampleClick = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select") - ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); + var channel = Enabled.Value ? sampleClick?.GetChannel() : sampleClickDisabled?.GetChannel(); - sampleClickDisabled = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select-disabled") - ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select-disabled"); + if (channel != null) + { + channel.Frequency.Value = 0.99 + RNG.NextDouble(0.02); + channel.Play(); + } } } } diff --git a/osu.Game/Graphics/UserInterface/LoadingLayer.cs b/osu.Game/Graphics/UserInterface/LoadingLayer.cs index 9059b61a33..916b041696 100644 --- a/osu.Game/Graphics/UserInterface/LoadingLayer.cs +++ b/osu.Game/Graphics/UserInterface/LoadingLayer.cs @@ -4,7 +4,6 @@ #nullable disable using System; -using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; @@ -22,9 +21,6 @@ namespace osu.Game.Graphics.UserInterface { private readonly bool blockInput; - [CanBeNull] - protected Box BackgroundDimLayer { get; } - /// /// Construct a new loading spinner. /// @@ -42,11 +38,11 @@ namespace osu.Game.Graphics.UserInterface if (dimBackground) { - AddInternal(BackgroundDimLayer = new Box + AddInternal(new Box { Depth = float.MaxValue, Colour = Color4.Black, - Alpha = 0, + Alpha = 0.5f, RelativeSizeAxes = Axes.Both, }); } @@ -74,23 +70,11 @@ namespace osu.Game.Graphics.UserInterface return true; } - protected override void PopIn() - { - BackgroundDimLayer?.FadeTo(0.5f, TRANSITION_DURATION * 2, Easing.OutQuint); - base.PopIn(); - } - - protected override void PopOut() - { - BackgroundDimLayer?.FadeOut(TRANSITION_DURATION, Easing.OutQuint); - base.PopOut(); - } - protected override void Update() { base.Update(); - MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 20, 100)); + MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 20, 80)); } } } diff --git a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs index df921c5c81..b4bc6fb8c3 100644 --- a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs +++ b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs @@ -1,10 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Backgrounds; using osuTK; using osuTK.Graphics; @@ -15,13 +19,19 @@ namespace osu.Game.Graphics.UserInterface /// public partial class LoadingSpinner : VisibilityContainer { + public const float TRANSITION_DURATION = 500; + private readonly SpriteIcon spinner; protected override bool StartHidden => true; protected Container MainContents; - public const float TRANSITION_DURATION = 500; + private readonly TrianglesV2 triangles; + + private readonly Container? trianglesMasking; + + private readonly bool withBox; private const float spin_duration = 900; @@ -32,37 +42,100 @@ namespace osu.Game.Graphics.UserInterface /// Whether colours should be inverted (black spinner instead of white). public LoadingSpinner(bool withBox = false, bool inverted = false) { + this.withBox = withBox; + Size = new Vector2(60); Anchor = Anchor.Centre; Origin = Anchor.Centre; - Child = MainContents = new Container + if (withBox) { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 20, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] + Child = MainContents = new Container { - new Box + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 20, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] { - Colour = inverted ? Color4.White : Color4.Black, - RelativeSizeAxes = Axes.Both, - Alpha = withBox ? 0.7f : 0 - }, - spinner = new SpriteIcon + new Box + { + Colour = inverted ? Color4.White : Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = 0.7f, + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Colour = inverted ? Color4.White : Color4.Black, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.2f, + ScaleAdjust = 0.4f, + Velocity = 0.8f, + SpawnRatio = 2 + }, + spinner = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = inverted ? Color4.Black : Color4.White, + Scale = new Vector2(0.6f), + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.CircleNotch + } + } + }; + } + else + { + Children = new[] + { + MainContents = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Colour = inverted ? Color4.Black : Color4.White, - Scale = new Vector2(withBox ? 0.6f : 1), RelativeSizeAxes = Axes.Both, - Icon = FontAwesome.Solid.CircleNotch - } - } - }; + Children = new Drawable[] + { + spinner = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = inverted ? Color4.Black : Color4.White, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.CircleNotch + } + } + }, + trianglesMasking = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Masking = true, + CornerRadius = 20, + Children = new Drawable[] + { + triangles = new TrianglesV2 + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.4f, + Colour = ColourInfo.GradientVertical( + inverted ? Color4.Black.Opacity(0) : Color4.White.Opacity(0), + inverted ? Color4.Black : Color4.White), + RelativeSizeAxes = Axes.Both, + ScaleAdjust = 0.4f, + SpawnRatio = 4, + }, + } + }, + }; + } } protected override void LoadComplete() @@ -72,11 +145,20 @@ namespace osu.Game.Graphics.UserInterface rotate(); } - protected override void Update() + protected override void UpdateAfterChildren() { - base.Update(); + base.UpdateAfterChildren(); - MainContents.CornerRadius = MainContents.DrawWidth / 4; + if (withBox) + { + MainContents.CornerRadius = MainContents.DrawWidth / 4; + triangles.Rotation = -MainContents.Rotation; + } + else + { + Debug.Assert(trianglesMasking != null); + trianglesMasking.CornerRadius = MainContents.DrawWidth / 2; + } } protected override void PopIn() @@ -86,13 +168,13 @@ namespace osu.Game.Graphics.UserInterface rotate(); MainContents.ScaleTo(1, TRANSITION_DURATION, Easing.OutQuint); - this.FadeIn(TRANSITION_DURATION * 2, Easing.OutQuint); + this.FadeIn(TRANSITION_DURATION, Easing.OutQuint); } protected override void PopOut() { - MainContents.ScaleTo(0.8f, TRANSITION_DURATION / 2, Easing.In); - this.FadeOut(TRANSITION_DURATION, Easing.OutQuint); + MainContents.ScaleTo(0.6f, TRANSITION_DURATION, Easing.OutQuint); + this.FadeOut(TRANSITION_DURATION / 2, Easing.OutQuint); } private void rotate() diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 0eec04541c..48d225de41 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -25,6 +25,8 @@ namespace osu.Game.Graphics.UserInterface private Color4 hoverColour = Color4.White.Opacity(0.1f); + protected float ScaleOnMouseDown { get; init; } = 0.75f; + /// /// The background colour of the while it is hovered. /// @@ -119,7 +121,7 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnMouseDown(MouseDownEvent e) { - Content.ScaleTo(0.75f, 2000, Easing.OutQuint); + Content.ScaleTo(ScaleOnMouseDown, 2000, Easing.OutQuint); return base.OnMouseDown(e); } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index 433d37834f..72ffde3574 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -12,17 +12,11 @@ namespace osu.Game.Graphics.UserInterface { public partial class OsuContextMenu : OsuMenu { - private const int fade_duration = 250; - [Resolved] private OsuMenuSamples menuSamples { get; set; } = null!; - // 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) + public OsuContextMenu(bool playSamples) + : base(Direction.Vertical, topLevelMenu: false, playSamples) { MaskingContainer.CornerRadius = 5; MaskingContainer.EdgeEffect = new EdgeEffectParameters @@ -35,8 +29,6 @@ namespace osu.Game.Graphics.UserInterface ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL }; MaxHeight = 250; - - this.playClickSample = playClickSample; } [BackgroundDependencyLoader] @@ -47,26 +39,12 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateOpen() { - wasOpened = true; - this.FadeIn(fade_duration, Easing.OutQuint); + if (PlaySamples && !WasOpened) + menuSamples.PlayClickSample(); - if (!playClickSample) - return; - - menuSamples.PlayClickSample(); - menuSamples.PlayOpenSample(); + base.AnimateOpen(); } - protected override void AnimateClose() - { - this.FadeOut(fade_duration, Easing.OutQuint); - - if (wasOpened) - menuSamples.PlayCloseSample(); - - wasOpened = false; - } - - protected override Menu CreateSubMenu() => new OsuContextMenu(); + protected override Menu CreateSubMenu() => new OsuContextMenu(false); // sub menu samples are handled by OsuMenu.OnSubmenuOpen. } } diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index dc42216c55..e0179f8bc4 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -18,6 +18,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -53,7 +54,7 @@ namespace osu.Game.Graphics.UserInterface #region OsuDropdownMenu - protected partial class OsuDropdownMenu : DropdownMenu + public partial class OsuDropdownMenu : DropdownMenu { public override bool HandleNonPositionalInput => State == MenuState.Open; @@ -252,6 +253,7 @@ namespace osu.Game.Graphics.UserInterface Size = new Vector2(8), Alpha = 0, X = chevron_offset, + Y = 1, Margin = new MarginPadding { Left = 3, Right = 3 }, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, @@ -439,6 +441,11 @@ namespace osu.Game.Graphics.UserInterface private partial class DropdownSearchTextBox : OsuTextBox { + public DropdownSearchTextBox() + { + PlaceholderText = HomeStrings.SearchPlaceholder; + } + [BackgroundDependencyLoader] private void load(OverlayColourProvider? colourProvider) { diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index 7cc1bab25f..11d9000dfa 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -18,21 +18,32 @@ namespace osu.Game.Graphics.UserInterface { public partial class OsuMenu : Menu { + protected const double DELAY_BEFORE_FADE_OUT = 50; + protected const double FADE_DURATION = 280; + // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. - private bool wasOpened; + protected bool WasOpened { get; private set; } + + public bool PlaySamples { get; } [Resolved] private OsuMenuSamples menuSamples { get; set; } = null!; public OsuMenu(Direction direction, bool topLevelMenu = false) + : this(direction, topLevelMenu, playSamples: !topLevelMenu) + { + } + + protected OsuMenu(Direction direction, bool topLevelMenu, bool playSamples) : base(direction, topLevelMenu) { + PlaySamples = playSamples; BackgroundColour = Color4.Black.Opacity(0.5f); MaskingContainer.CornerRadius = 4; ItemsContainer.Padding = new MarginPadding(5); - OnSubmenuOpen += _ => { menuSamples?.PlaySubOpenSample(); }; + OnSubmenuOpen += _ => menuSamples?.PlaySubOpenSample(); } protected override void Update() @@ -56,20 +67,22 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateOpen() { - if (!TopLevelMenu && !wasOpened) + if (PlaySamples && !WasOpened) menuSamples?.PlayOpenSample(); - this.FadeIn(300, Easing.OutQuint); - wasOpened = true; + WasOpened = true; + this.FadeIn(FADE_DURATION, Easing.OutQuint); } protected override void AnimateClose() { - if (!TopLevelMenu && wasOpened) + if (PlaySamples && WasOpened) menuSamples?.PlayCloseSample(); - this.FadeOut(300, Easing.OutQuint); - wasOpened = false; + this.Delay(DELAY_BEFORE_FADE_OUT) + .FadeOut(FADE_DURATION, Easing.OutQuint); + + WasOpened = false; } protected override void UpdateSize(Vector2 newSize) @@ -77,12 +90,21 @@ namespace osu.Game.Graphics.UserInterface if (Direction == Direction.Vertical) { Width = newSize.X; - this.ResizeHeightTo(newSize.Y, 300, Easing.OutQuint); + + if (newSize.Y > 0) + this.ResizeHeightTo(newSize.Y, 300, Easing.OutQuint); + else + // Delay until the fade out finishes from AnimateClose. + this.Delay(DELAY_BEFORE_FADE_OUT + FADE_DURATION).ResizeHeightTo(0); } else { Height = newSize.Y; - this.ResizeWidthTo(newSize.X, 300, Easing.OutQuint); + if (newSize.X > 0) + this.ResizeWidthTo(newSize.X, 300, Easing.OutQuint); + else + // Delay until the fade out finishes from AnimateClose. + this.Delay(DELAY_BEFORE_FADE_OUT + FADE_DURATION).ResizeWidthTo(0); } } diff --git a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs index f122990a0f..0fbe4bf877 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; @@ -13,6 +14,8 @@ namespace osu.Game.Graphics.UserInterface public Hotkey Hotkey { get; init; } + public IconUsage Icon { get; init; } + public OsuMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard) : this(text, type, null) { diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index db4b7b2ab3..991ab6798f 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -1,17 +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.Input; + namespace osu.Game.Graphics.UserInterface { public partial class OsuNumberBox : OsuTextBox { - protected override bool AllowIme => false; - public OsuNumberBox() { + InputProperties = new TextInputProperties(TextInputType.Number, false); + SelectAllOnFocus = true; } - - protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); } } diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 0be7b4dc48..143962542d 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -18,7 +18,7 @@ using osu.Game.Localisation; namespace osu.Game.Graphics.UserInterface { - public partial class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging + public partial class OsuPasswordTextBox : OsuTextBox { protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { @@ -28,12 +28,6 @@ namespace osu.Game.Graphics.UserInterface protected override bool AllowUniqueCharacterSamples => false; - protected override bool AllowClipboardExport => false; - - protected override bool AllowWordNavigation => false; - - protected override bool AllowIme => false; - private readonly CapsWarning warning; [Resolved] @@ -41,6 +35,8 @@ namespace osu.Game.Graphics.UserInterface public OsuPasswordTextBox() { + InputProperties = new TextInputProperties(TextInputType.Password, false); + Add(warning = new CapsWarning { Size = new Vector2(20), diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 334fe343ae..ca95d45042 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Numerics; -using System.Globalization; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -11,13 +9,15 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Framework.Utils; -using osu.Game.Utils; +using osu.Game.Extensions; namespace osu.Game.Graphics.UserInterface { public abstract partial class OsuSliderBar : SliderBar, IHasTooltip where T : struct, INumber, IMinMaxValue { + public override bool AcceptsFocus => !Current.Disabled; + public bool PlaySamplesOnAdjust { get; set; } = true; /// @@ -83,35 +83,6 @@ namespace osu.Game.Graphics.UserInterface channel.Play(); } - public LocalisableString GetDisplayableValue(T value) - { - if (CurrentNumber.IsInteger) - return int.CreateTruncating(value).ToString("N0"); - - double floatValue = double.CreateTruncating(value); - - decimal decimalPrecision = normalise(decimal.CreateTruncating(CurrentNumber.Precision), max_decimal_digits); - - // Find the number of significant digits (we could have less than 5 after normalize()) - int significantDigits = FormatUtils.FindPrecision(decimalPrecision); - - if (DisplayAsPercentage) - { - return floatValue.ToString($@"P{Math.Max(0, significantDigits - 2)}"); - } - - string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? "-" : string.Empty; - - return $"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}")}"; - } - - /// - /// Removes all non-significant digits, keeping at most a requested number of decimal digits. - /// - /// The decimal to normalize. - /// The maximum number of decimal digits to keep. The final result may have fewer decimal digits than this value. - /// The normalised decimal. - private decimal normalise(decimal d, int sd) - => decimal.Parse(Math.Round(d, sd).ToString(string.Concat("0.", new string('#', sd)), CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + public LocalisableString GetDisplayableValue(T value) => value.ToStandardFormattedString(max_decimal_digits, DisplayAsPercentage); } } diff --git a/osu.Game/Graphics/UserInterface/ProgressBar.cs b/osu.Game/Graphics/UserInterface/ProgressBar.cs index 8f383c76db..dcf96f04c0 100644 --- a/osu.Game/Graphics/UserInterface/ProgressBar.cs +++ b/osu.Game/Graphics/UserInterface/ProgressBar.cs @@ -13,6 +13,8 @@ namespace osu.Game.Graphics.UserInterface { public partial class ProgressBar : SliderBar { + public bool Seeking { get; private set; } + public Action OnSeek; private readonly Box fill; @@ -75,6 +77,16 @@ namespace osu.Game.Graphics.UserInterface fill.Width = value * UsableWidth; } - protected override void OnUserChange(double value) => OnSeek?.Invoke(value); + protected override void OnUserChange(double value) + { + Seeking = true; + } + + protected override bool Commit() + { + OnSeek?.Invoke(CurrentNumber.Value); + Seeking = false; + return base.Commit(); + } } } diff --git a/osu.Game/Graphics/UserInterface/RangeSlider.cs b/osu.Game/Graphics/UserInterface/RangeSlider.cs index 422c2ca4a3..acf10ce827 100644 --- a/osu.Game/Graphics/UserInterface/RangeSlider.cs +++ b/osu.Game/Graphics/UserInterface/RangeSlider.cs @@ -162,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface protected partial class BoundSlider : RoundedSliderBar { + public override bool AcceptsFocus => false; + public new Nub Nub => base.Nub; public string? DefaultString; diff --git a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs index aeab7c34b2..9a0183da64 100644 --- a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Overlays; using Vector2 = osuTK.Vector2; @@ -52,10 +53,21 @@ namespace osu.Game.Graphics.UserInterface } } + /// + /// The action to use to reset the value of to the default. + /// Triggered on double click. + /// + public Action ResetToDefault { get; internal set; } + public RoundedSliderBar() { Height = Nub.HEIGHT; RangePadding = Nub.DEFAULT_EXPANDED_SIZE / 2; + ResetToDefault = () => + { + if (!Current.Disabled) + Current.SetDefault(); + }; Children = new Drawable[] { new Container @@ -102,11 +114,7 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.TopCentre, RelativePositionAxes = Axes.X, Current = { Value = true }, - OnDoubleClicked = () => - { - if (!Current.Disabled) - Current.SetDefault(); - }, + OnDoubleClicked = () => ResetToDefault.Invoke(), }, }, hoverClickSounds = new HoverClickSounds() diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index a2e0ab6482..17d714d029 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -54,7 +54,13 @@ namespace osu.Game.Graphics.UserInterface { case Key.KeypadEnter: case Key.Enter: - return false; + // even if committing per se is not allowed for this textbox, + // the commit flow is also responsible for terminating any active IME. + // ensure that the Enter press terminates IME correctly + // and is also handled if it needs to be, so that it doesn't leak to some other non-focused drawable and cause breakage. + bool wasImeComposing = ImeCompositionActive; + FinalizeImeComposition(true); + return wasImeComposing; } } diff --git a/osu.Game/Graphics/UserInterface/ShearedButton.cs b/osu.Game/Graphics/UserInterface/ShearedButton.cs index 87d269ccd4..2047fc74f4 100644 --- a/osu.Game/Graphics/UserInterface/ShearedButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedButton.cs @@ -11,7 +11,6 @@ using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; -using osuTK; namespace osu.Game.Graphics.UserInterface { @@ -66,8 +65,6 @@ namespace osu.Game.Graphics.UserInterface private readonly Box background; private readonly OsuSpriteText text; - private const float shear = OsuGame.SHEAR; - private Colour4? darkerColour; private Colour4? lighterColour; private Colour4? textColour; @@ -91,12 +88,12 @@ namespace osu.Game.Graphics.UserInterface public ShearedButton(float? width = null, float height = DEFAULT_HEIGHT) { Height = height; - Padding = new MarginPadding { Horizontal = shear * height }; - Content.CornerRadius = CORNER_RADIUS; - Content.Shear = new Vector2(shear, 0); - Content.Masking = true; + Shear = OsuGame.SHEAR; + Content.Anchor = Content.Origin = Anchor.Centre; + Content.CornerRadius = CORNER_RADIUS; + Content.Masking = true; Children = new Drawable[] { @@ -117,7 +114,7 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.Centre, Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, - Shear = new Vector2(-shear, 0), + Shear = -OsuGame.SHEAR, Child = text = new OsuSpriteText { Font = OsuFont.TorusAlternate.With(size: 17), @@ -182,7 +179,7 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnMouseDown(MouseDownEvent e) { Content.ScaleTo(0.9f, 2000, Easing.OutQuint); - return base.OnMouseDown(e); + return true; } protected override void OnMouseUp(MouseUpEvent e) diff --git a/osu.Game/Graphics/UserInterface/ShearedFilterTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedFilterTextBox.cs new file mode 100644 index 0000000000..635990ec9c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShearedFilterTextBox.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class ShearedFilterTextBox : ShearedSearchTextBox + { + private const float filter_text_size = 12; + + public LocalisableString StatusText + { + get => ((InnerFilterTextBox)TextBox).StatusText.Text; + set => Schedule(() => ((InnerFilterTextBox)TextBox).StatusText.Text = value); + } + + public ShearedFilterTextBox() + { + Height += filter_text_size; + } + + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerFilterTextBox(); + + protected partial class InnerFilterTextBox : InnerSearchTextBox + { + public OsuSpriteText StatusText { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + TextContainer.Add(StatusText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Torus.With(size: filter_text_size, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Top = 2, Left = -1 }, + Colour = colours.Yellow + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TextContainer.Height *= (DrawHeight - filter_text_size) / DrawHeight; + TextContainer.Margin = new MarginPadding { Bottom = filter_text_size }; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ShearedNub.cs b/osu.Game/Graphics/UserInterface/ShearedNub.cs index 7485f68525..0021c1cbd2 100644 --- a/osu.Game/Graphics/UserInterface/ShearedNub.cs +++ b/osu.Game/Graphics/UserInterface/ShearedNub.cs @@ -21,39 +21,54 @@ namespace osu.Game.Graphics.UserInterface { public Action? OnDoubleClicked { get; init; } - protected const float BORDER_WIDTH = 3; - public const int HEIGHT = 30; public const float EXPANDED_SIZE = 50; - - public static readonly Vector2 SHEAR = new Vector2(0.15f, 0); + public const float CORNER_RADIUS = 5; private readonly Box fill; private readonly Container main; + private readonly Container shadow; - /// - /// Implements the shape for the nub, allowing for any type of container to be used. - /// - /// public ShearedNub() { Size = new Vector2(EXPANDED_SIZE, HEIGHT); - InternalChild = main = new Container + InternalChildren = new Drawable[] { - Shear = SHEAR, - BorderColour = Colour4.White, - BorderThickness = BORDER_WIDTH, - Masking = true, - CornerRadius = 5, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Child = fill = new Box + shadow = new Container { + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = CORNER_RADIUS, RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, - } + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 20f, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }, + main = new Container + { + Shear = OsuGame.SHEAR, + BorderColour = Colour4.White, + BorderThickness = 8f, + Masking = true, + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = fill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }, }; } @@ -78,6 +93,7 @@ namespace osu.Game.Graphics.UserInterface base.LoadComplete(); Current.BindValueChanged(onCurrentValueChanged, true); + FinishTransforms(true); } private bool glowing; @@ -91,22 +107,22 @@ namespace osu.Game.Graphics.UserInterface return; glowing = value; + updateDisplay(); + } + } - if (value) - { - main.FadeColour(GlowingAccentColour.Lighten(0.1f), 40, Easing.OutQuint) - .Then() - .FadeColour(GlowingAccentColour, 800, Easing.OutQuint); + private Color4 shadowColour = Color4.Black.Opacity(0f); - main.FadeEdgeEffectTo(Color4.White.Opacity(0.1f), 40, Easing.OutQuint) - .Then() - .FadeEdgeEffectTo(GlowColour.Opacity(0.1f), 800, Easing.OutQuint); - } - else - { - main.FadeEdgeEffectTo(GlowColour.Opacity(0), 800, Easing.OutQuint); - main.FadeColour(AccentColour, 800, Easing.OutQuint); - } + public Color4 ShadowColour + { + get => shadowColour; + set + { + if (shadowColour == value) + return; + + shadowColour = value; + shadow.FadeEdgeEffectTo(value, 800, Easing.OutQuint); } } @@ -132,8 +148,7 @@ namespace osu.Game.Graphics.UserInterface set { accentColour = value; - if (!Glowing) - main.Colour = value; + updateDisplay(); } } @@ -145,8 +160,7 @@ namespace osu.Game.Graphics.UserInterface set { glowingAccentColour = value; - if (Glowing) - main.Colour = value; + updateDisplay(); } } @@ -158,10 +172,7 @@ namespace osu.Game.Graphics.UserInterface set { glowColour = value; - - var effect = main.EdgeEffect; - effect.Colour = Glowing ? value : value.Opacity(0); - main.EdgeEffect = effect; + updateDisplay(); } } @@ -179,7 +190,26 @@ namespace osu.Game.Graphics.UserInterface else { main.ResizeWidthTo(0.75f, duration, Easing.OutQuint); - main.TransformTo(nameof(BorderThickness), BORDER_WIDTH, duration, Easing.OutQuint); + main.TransformTo(nameof(BorderThickness), 8f, duration, Easing.OutQuint); + } + } + + private void updateDisplay() + { + if (Glowing) + { + main.FadeColour(GlowingAccentColour.Lighten(0.1f), 40, Easing.OutQuint) + .Then() + .FadeColour(GlowingAccentColour, 800, Easing.OutQuint); + + main.FadeEdgeEffectTo(Color4.White.Opacity(0.1f), 40, Easing.OutQuint) + .Then() + .FadeEdgeEffectTo(GlowColour.Opacity(0.1f), 800, Easing.OutQuint); + } + else + { + main.FadeEdgeEffectTo(GlowColour.Opacity(0), 800, Easing.OutQuint); + main.FadeColour(AccentColour, 800, Easing.OutQuint); } } diff --git a/osu.Game/Graphics/UserInterface/ShearedRangeSlider.cs b/osu.Game/Graphics/UserInterface/ShearedRangeSlider.cs new file mode 100644 index 0000000000..417474cba3 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShearedRangeSlider.cs @@ -0,0 +1,278 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +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.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class ShearedRangeSlider : CompositeDrawable + { + private readonly LocalisableString label; + + private readonly BindableNumberWithCurrent lowerBound = new BindableNumberWithCurrent(); + + /// + /// The lower limiting value. + /// + public Bindable LowerBound + { + get => lowerBound.Current; + set => lowerBound.Current = value; + } + + private readonly BindableNumberWithCurrent upperBound = new BindableNumberWithCurrent(); + + /// + /// The upper limiting value. + /// + public Bindable UpperBound + { + get => upperBound.Current; + set => upperBound.Current = value; + } + + public float NubWidth { get; init; } + + /// + /// Minimum difference between the lower bound and higher bound + /// + public float MinRange + { + set => minRange = value; + } + + /// + /// Lower bound display for when it is set to its default value, or null to display the value directly. + /// + public LocalisableString? DefaultStringLowerBound { get; init; } + + /// + /// Upper bound display for when it is set to its default value, or null to display the value directly. + /// + public LocalisableString? DefaultStringUpperBound { get; init; } + + private float minRange = 0.1f; + + protected Container SliderContainer { get; private set; } = null!; + + protected BoundSliderBar LowerBoundSlider { get; private set; } = null!; + protected BoundSliderBar UpperBoundSlider { get; private set; } = null!; + + protected Vector2 ScreenSpaceHalfwayPoint + { + get + { + var lowerSS = LowerBoundSlider.Nub.ScreenSpaceDrawQuad.TopLeft; + var upperSS = UpperBoundSlider.Nub.ScreenSpaceDrawQuad.TopLeft; + + return lowerSS + (upperSS - lowerSS) / 2; + } + } + + public ShearedRangeSlider(LocalisableString label) + { + this.label = label; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = ShearedNub.HEIGHT; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new[] + { + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 5f, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = label, + Shear = -OsuGame.SHEAR, + Margin = new MarginPadding { Horizontal = 12, Vertical = 5 }, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + }, + }, + SliderContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = -10 }, + Children = new[] + { + UpperBoundSlider = CreateBoundSlider(true).With(d => + { + d.KeyboardStep = 0.1f; + d.RelativeSizeAxes = Axes.X; + d.DefaultString = DefaultStringUpperBound; + d.NubWidth = NubWidth; + d.Current = upperBound; + }), + LowerBoundSlider = CreateBoundSlider(false).With(d => + { + d.KeyboardStep = 0.1f; + d.RelativeSizeAxes = Axes.X; + d.DefaultString = DefaultStringLowerBound; + d.NubWidth = NubWidth; + d.Current = lowerBound; + }), + UpperBoundSlider.Nub.CreateProxy(), + LowerBoundSlider.Nub.CreateProxy(), + }, + }, + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + LowerBoundSlider.Current.ValueChanged += min => UpperBoundSlider.Current.Value = Math.Max(min.NewValue + minRange, UpperBoundSlider.Current.Value); + UpperBoundSlider.Current.ValueChanged += max => LowerBoundSlider.Current.Value = Math.Min(max.NewValue - minRange, LowerBoundSlider.Current.Value); + } + + protected virtual BoundSliderBar CreateBoundSlider(bool isUpper) => new BoundSliderBar(this, isUpper); + + protected partial class BoundSliderBar : ShearedSliderBar + { + private readonly ShearedRangeSlider rangeSlider; + private readonly bool isUpper; + + public new float NormalizedValue => base.NormalizedValue; + + public new ShearedNub Nub => base.Nub; + + public LocalisableString? DefaultString; + + public float NubWidth { get; set; } = ShearedNub.HEIGHT; + + public override LocalisableString TooltipText + { + get + { + if (Current.IsDefault) + return string.Empty; + + return Current.Value.ToLocalisableString(@"N1"); + } + } + + protected OsuSpriteText NubText { get; private set; } = null!; + + public override bool AcceptsFocus => false; + + public BoundSliderBar(ShearedRangeSlider rangeSlider, bool isUpper) + { + this.rangeSlider = rangeSlider; + this.isUpper = isUpper; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Nub.Width = NubWidth; + RangePadding = Nub.Width / 2; + + Nub.Add(NubText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = -3, + UseFullGlyphHeight = false, + Colour = OsuColour.ForegroundTextColourFor(colourProvider.Light1), + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }); + + AccentColour = colourProvider.Highlight1.Darken(0.1f); + Nub.AccentColour = colourProvider.Highlight1; + Nub.GlowingAccentColour = colourProvider.Highlight1; + Nub.GlowColour = colourProvider.Highlight1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (!isUpper) + { + AccentColour = BackgroundColour; + BackgroundColour = Color4.Transparent; + } + + Current.BindValueChanged(current => UpdateDisplay(current.NewValue), true); + FinishTransforms(true); + } + + protected virtual void UpdateDisplay(double value) + { + if (Current.IsDefault && DefaultString != null) + NubText.Text = DefaultString.Value; + else + NubText.Text = value.ToLocalisableString(@"N1"); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (isUpper) + return base.ReceivePositionalInputAt(screenSpacePos) && screenSpacePos.X > rangeSlider.ScreenSpaceHalfwayPoint.X; + + return base.ReceivePositionalInputAt(screenSpacePos) && screenSpacePos.X <= rangeSlider.ScreenSpaceHalfwayPoint.X; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (isUpper) + { + // Only draw left box where required to avoid masking bleed issues. + LeftBox.X = ToParentSpace(ToLocalSpace(rangeSlider.LowerBoundSlider.Nub.ScreenSpaceDrawQuad.Centre)).X; + LeftBox.Size -= new Vector2(LeftBox.X, 0); + } + } + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + return true; // Make sure only one nub shows hover effect at once. + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs index c6565726b5..b1b93dcbca 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs @@ -21,38 +21,38 @@ namespace osu.Game.Graphics.UserInterface private const float corner_radius = 7; private readonly Box background; - private readonly SearchTextBox textBox; + protected readonly InnerSearchTextBox TextBox; public Bindable Current { - get => textBox.Current; - set => textBox.Current = value; + get => TextBox.Current; + set => TextBox.Current = value; } public bool HoldFocus { - get => textBox.HoldFocus; - set => textBox.HoldFocus = value; + get => TextBox.HoldFocus; + set => TextBox.HoldFocus = value; } public LocalisableString PlaceholderText { - get => textBox.PlaceholderText; - set => textBox.PlaceholderText = value; + get => TextBox.PlaceholderText; + set => TextBox.PlaceholderText = value; } - public new bool HasFocus => textBox.HasFocus; + public new bool HasFocus => TextBox.HasFocus; - public void TakeFocus() => textBox.TakeFocus(); + public void TakeFocus() => TextBox.TakeFocus(); - public void KillFocus() => textBox.KillFocus(); + public void KillFocus() => TextBox.KillFocus(); - public bool SelectAll() => textBox.SelectAll(); + public bool SelectAll() => TextBox.SelectAll(); public ShearedSearchTextBox() { Height = 42; - Shear = new Vector2(OsuGame.SHEAR, 0); + Shear = OsuGame.SHEAR; Masking = true; CornerRadius = corner_radius; @@ -69,13 +69,7 @@ namespace osu.Game.Graphics.UserInterface { new Drawable[] { - textBox = new InnerSearchTextBox - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Size = Vector2.One - }, + TextBox = CreateInnerTextBox(), new SpriteIcon { Icon = FontAwesome.Solid.Search, @@ -101,10 +95,20 @@ namespace osu.Game.Graphics.UserInterface background.Colour = colourProvider.Background3; } - public override bool HandleNonPositionalInput => textBox.HandleNonPositionalInput; + public override bool HandleNonPositionalInput => TextBox.HandleNonPositionalInput; - private partial class InnerSearchTextBox : SearchTextBox + protected virtual InnerSearchTextBox CreateInnerTextBox() => new InnerSearchTextBox(); + + protected partial class InnerSearchTextBox : SearchTextBox { + public InnerSearchTextBox() + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + RelativeSizeAxes = Axes.Both; + Size = Vector2.One; + } + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { @@ -115,7 +119,7 @@ namespace osu.Game.Graphics.UserInterface PlaceholderText = CommonStrings.InputSearch; CornerRadius = corner_radius; - TextContainer.Shear = new Vector2(-OsuGame.SHEAR, 0); + TextContainer.Shear = -OsuGame.SHEAR; } protected override SpriteText CreatePlaceholder() => new SearchPlaceholder(); diff --git a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs index a36b9c7a4c..9404b813f9 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Overlays; -using static osu.Game.Graphics.UserInterface.ShearedNub; using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterface @@ -29,6 +28,8 @@ namespace osu.Game.Graphics.UserInterface private readonly Container mainContent; + protected virtual bool FocusIndicator => true; + private Color4 accentColour; public Color4 AccentColour @@ -56,53 +57,50 @@ namespace osu.Game.Graphics.UserInterface } } + public Color4 NubShadowColour + { + get => Nub.ShadowColour; + set => Nub.ShadowColour = value; + } + public ShearedSliderBar() { - Shear = SHEAR; - Height = HEIGHT; - RangePadding = EXPANDED_SIZE / 2; + Shear = OsuGame.SHEAR; + Height = ShearedNub.HEIGHT; + RangePadding = ShearedNub.EXPANDED_SIZE / 2; Children = new Drawable[] { mainContent = new Container { RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Child = new Container + Masking = true, + CornerRadius = 5, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Masking = true, - CornerRadius = 5, - Children = new Drawable[] + LeftBox = new Box { - LeftBox = new Box - { - EdgeSmoothness = new Vector2(0, 0.5f), - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - RightBox = new Box - { - EdgeSmoothness = new Vector2(0, 0.5f), - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - }, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + RightBox = new Box + { + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, }, }, }, nubContainer = new Container { - Shear = -SHEAR, + Shear = -OsuGame.SHEAR, RelativeSizeAxes = Axes.Both, Child = Nub = new ShearedNub { - X = -SHEAR.X * HEIGHT / 2f, Origin = Anchor.TopCentre, RelativePositionAxes = Axes.X, Current = { Value = true }, @@ -146,13 +144,16 @@ namespace osu.Game.Graphics.UserInterface { base.OnFocus(e); - mainContent.EdgeEffect = new EdgeEffectParameters + if (FocusIndicator) { - Type = EdgeEffectType.Glow, - Colour = AccentColour.Darken(1), - Hollow = true, - Radius = 2, - }; + mainContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour.Darken(1), + Hollow = true, + Radius = 2, + }; + } } protected override void OnFocusLost(FocusLostEvent e) @@ -191,8 +192,9 @@ namespace osu.Game.Graphics.UserInterface protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); - RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); + + LeftBox.Size = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2f + ShearedNub.CORNER_RADIUS - 0.5f, 0, Math.Max(0, DrawWidth)), 1); + RightBox.Size = new Vector2(Math.Clamp(DrawWidth - RangePadding - Nub.DrawPosition.X - Nub.DrawWidth / 2f + ShearedNub.CORNER_RADIUS - 0.5f, 0, Math.Max(0, DrawWidth)), 1); } protected override void UpdateValue(float value) diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs index 05ed531d02..c2f547ba19 100644 --- a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs @@ -10,7 +10,6 @@ namespace osu.Game.Graphics.UserInterface { public partial class ShearedToggleButton : ShearedButton { - private Sample? sampleClick; private Sample? sampleOff; private Sample? sampleOn; @@ -43,9 +42,8 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(AudioManager audio) { - sampleClick = audio.Samples.Get(@"UI/default-select"); - sampleOn = audio.Samples.Get(@"UI/dropdown-open"); - sampleOff = audio.Samples.Get(@"UI/dropdown-close"); + sampleOn = audio.Samples.Get(@"UI/check-on"); + sampleOff = audio.Samples.Get(@"UI/check-off"); } protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); @@ -72,8 +70,6 @@ namespace osu.Game.Graphics.UserInterface private void playSample() { - sampleClick?.Play(); - if (PlayToggleSamples) { if (Active.Value) diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs index aeeda82bfb..efeebb2fc1 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs @@ -9,7 +9,6 @@ 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; using osu.Game.Overlays; using osuTK; @@ -78,13 +77,9 @@ namespace osu.Game.Graphics.UserInterfaceV2.FileSelection Flow.Height = 25; Flow.Margin = new MarginPadding { Horizontal = 10, }; - AddRangeInternal(new Drawable[] + AddInternal(new BackgroundLayer(0.5f) { - new BackgroundLayer(0.5f) - { - Depth = 1 - }, - new HoverClickSounds(), + Depth = 1 }); Flow.Add(new SpriteIcon diff --git a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs new file mode 100644 index 0000000000..85198191b8 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs @@ -0,0 +1,189 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormButton : CompositeDrawable + { + /// + /// Caption describing this button, displayed on the left of it. + /// + public LocalisableString Caption { get; init; } + + public LocalisableString ButtonText { get; init; } + + public Action? Action { get; init; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Height = 50; + + Masking = true; + CornerRadius = 5; + CornerExponent = 2.5f; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Left = 9, + Right = 5, + Vertical = 5, + }, + Children = new Drawable[] + { + new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.45f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = Caption, + }, + new Button + { + Action = Action, + Text = ButtonText, + RelativeSizeAxes = ButtonText == default ? Axes.None : Axes.X, + Width = ButtonText == default ? 90 : 0.45f, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } + }, + }, + }; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + private void updateState() + { + BorderThickness = IsHovered ? 2 : 0; + + if (IsHovered) + BorderColour = colourProvider.Light4; + } + + public partial class Button : OsuButton + { + private TrianglesV2? triangles { get; set; } + + protected override float HoverLayerFinalAlpha => 0; + + private Color4? triangleGradientSecondColour; + + public override Color4 BackgroundColour + { + get => base.BackgroundColour; + set + { + base.BackgroundColour = value; + triangleGradientSecondColour = BackgroundColour.Lighten(0.2f); + updateColours(); + } + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider overlayColourProvider) + { + DefaultBackgroundColour = overlayColourProvider.Colour3; + triangleGradientSecondColour ??= DefaultBackgroundColour.Lighten(0.2f); + + if (Text == default) + { + Add(new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(16), + Shadow = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Content.CornerRadius = 4; + + Add(triangles = new TrianglesV2 + { + Thickness = 0.02f, + SpawnRatio = 0.6f, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + }); + + updateColours(); + } + + private void updateColours() + { + if (triangles == null) + return; + + Debug.Assert(triangleGradientSecondColour != null); + + triangles.Colour = ColourInfo.GradientVertical(triangleGradientSecondColour.Value, BackgroundColour); + } + + protected override bool OnHover(HoverEvent e) + { + Debug.Assert(triangleGradientSecondColour != null); + + Background.FadeColour(triangleGradientSecondColour.Value, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Background.FadeColour(BackgroundColour, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs index fad58841e3..a0348fa27a 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs @@ -31,9 +31,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 public LocalisableString Caption { get; init; } public LocalisableString HintText { get; init; } + public BindableBool CanAdd { get; } = new BindableBool(true); + private Box background = null!; private FormFieldCaption caption = null!; private FillFlowContainer flow = null!; + private RoundedButton addButton = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -47,8 +50,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 Masking = true; CornerRadius = 5; - RoundedButton button; - InternalChildren = new Drawable[] { background = new Box @@ -76,7 +77,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, Spacing = new Vector2(5), - Child = button = new RoundedButton + Child = addButton = new RoundedButton { Action = addNewColour, Size = new Vector2(70), @@ -87,7 +88,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, }; - flow.SetLayoutPosition(button, float.MaxValue); + flow.SetLayoutPosition(addButton, float.MaxValue); } protected override void LoadComplete() @@ -99,6 +100,19 @@ namespace osu.Game.Graphics.UserInterfaceV2 if (args.Action != NotifyCollectionChangedAction.Replace) updateColours(); }, true); + CanAdd.BindValueChanged(canAdd => + { + if (canAdd.NewValue) + { + addButton.Enabled.Value = true; + addButton.TooltipText = string.Empty; + } + else + { + addButton.Enabled.Value = false; + addButton.TooltipText = "Maximum combo colours reached"; + } + }, true); updateState(); } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs index 81023417a5..5fdf453fc4 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs @@ -242,20 +242,26 @@ namespace osu.Game.Graphics.UserInterfaceV2 Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); + protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) => new FileChooserPopover(handledExtensions, current, chooserPath); + public Popover GetPopover() { - var popover = new FileChooserPopover(handledExtensions, Current, initialChooserPath); + var popover = CreatePopover(handledExtensions, Current, initialChooserPath); popoverState.UnbindBindings(); popoverState.BindTo(popover.State); return popover; } - private partial class FileChooserPopover : OsuPopover + protected partial class FileChooserPopover : OsuPopover { protected override string PopInSampleName => "UI/overlay-big-pop-in"; protected override string PopOutSampleName => "UI/overlay-big-pop-out"; - public FileChooserPopover(string[] handledExtensions, Bindable currentFile, string? chooserPath) + private readonly Bindable current = new Bindable(); + + protected OsuFileSelector FileSelector; + + public FileChooserPopover(string[] handledExtensions, Bindable current, string? chooserPath) : base(false) { Child = new Container @@ -264,12 +270,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 // simplest solution to avoid underlying text to bleed through the bottom border // https://github.com/ppy/osu/pull/30005#issuecomment-2378884430 Padding = new MarginPadding { Bottom = 1 }, - Child = new OsuFileSelector(chooserPath, handledExtensions) + Child = FileSelector = new OsuFileSelector(chooserPath, handledExtensions) { RelativeSizeAxes = Axes.Both, - CurrentFile = { BindTarget = currentFile } }, }; + + this.current.BindTo(current); } [BackgroundDependencyLoader] @@ -292,6 +299,19 @@ namespace osu.Game.Graphics.UserInterfaceV2 } }); } + + protected override void LoadComplete() + { + base.LoadComplete(); + + FileSelector.CurrentFile.ValueChanged += f => + { + if (f.NewValue != null) + OnFileSelected(f.NewValue); + }; + } + + protected virtual void OnFileSelected(FileInfo file) => current.Value = file; } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs index c3256e0038..b739155a36 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs @@ -1,26 +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.Globalization; +using osu.Framework.Input; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class FormNumberBox : FormTextBox { - public bool AllowDecimals { get; init; } + private readonly bool allowDecimals; - internal override InnerTextBox CreateTextBox() => new InnerNumberBox + public FormNumberBox(bool allowDecimals = false) + { + this.allowDecimals = allowDecimals; + } + + internal override InnerTextBox CreateTextBox() => new InnerNumberBox(allowDecimals) { - AllowDecimals = AllowDecimals, SelectAllOnFocus = true, }; internal partial class InnerNumberBox : InnerTextBox { - public bool AllowDecimals { get; init; } - - protected override bool CanAddCharacter(char character) - => char.IsAsciiDigit(character) || (AllowDecimals && CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator.Contains(character)); + public InnerNumberBox(bool allowDecimals) + { + InputProperties = new TextInputProperties(allowDecimals ? TextInputType.Decimal : TextInputType.Number, false); + } } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index 532423876e..1304c298fb 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -68,6 +68,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// public LocalisableString HintText { get; init; } + /// + /// A custom step value for each key press which actuates a change on this control. + /// + public float KeyboardStep { get; init; } + private Box background = null!; private Box flashLayer = null!; private FormTextBox.InnerTextBox textBox = null!; @@ -119,7 +124,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 Caption = Caption, TooltipText = HintText, }, - textBox = new FormNumberBox.InnerNumberBox + textBox = new FormNumberBox.InnerNumberBox(allowDecimals: true) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, @@ -127,7 +132,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 Width = 0.5f, CommitOnFocusLost = true, SelectAllOnFocus = true, - AllowDecimals = true, OnInputError = () => { flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); @@ -141,6 +145,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 Origin = Anchor.CentreRight, RelativeSizeAxes = Axes.X, Width = 0.5f, + KeyboardStep = KeyboardStep, Current = currentNumberInstantaneous, OnCommit = () => current.Value = currentNumberInstantaneous.Value, } @@ -307,6 +312,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 Height = 40; RelativeSizeAxes = Axes.X; RangePadding = nub_width / 2; + Children = new Drawable[] { new Container diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs index 9b4689958c..7abaca4092 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs @@ -14,7 +14,6 @@ using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osuTK; -using osuTK.Input; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -75,14 +74,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 samplePopOut?.Play(); } - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Key == Key.Escape) - return false; // disable the framework-level handling of escape key for conformity (we use GlobalAction.Back). - - return base.OnKeyDown(e); - } - public virtual bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs index 6aded3fe32..bf92f20526 100644 --- a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -8,6 +8,7 @@ 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.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.Backgrounds; @@ -17,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { - public partial class RoundedButton : OsuButton, IFilterable + public partial class RoundedButton : OsuButton, IFilterable, IHasTooltip { protected TrianglesV2? Triangles { get; private set; } @@ -53,7 +54,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { // Many buttons have local colours, but this provides a sane default for all other cases. DefaultBackgroundColour = overlayColourProvider?.Colour3 ?? colours.Blue3; - triangleGradientSecondColour ??= overlayColourProvider?.Colour1 ?? colours.Blue3.Lighten(0.2f); + triangleGradientSecondColour ??= DefaultBackgroundColour.Lighten(0.2f); } protected override void LoadComplete() @@ -107,5 +108,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } public bool FilteringActive { get; set; } + + public virtual LocalisableString TooltipText { get; set; } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/ShearedDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/ShearedDropdown.cs new file mode 100644 index 0000000000..af69aefaaf --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/ShearedDropdown.cs @@ -0,0 +1,292 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class ShearedDropdown : Dropdown, IKeyBindingHandler + { + protected override DropdownHeader CreateHeader() => new ShearedDropdownHeader(); + + protected override DropdownMenu CreateMenu() => new ShearedDropdownMenu(); + + public ShearedDropdown(LocalisableString label) + { + if (Header is ShearedDropdownHeader osuHeader) + { + osuHeader.Dropdown = this; + osuHeader.LeftSideLabel = label; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) return false; + + if (e.Action == GlobalAction.Back) + return Back(); + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + protected partial class ShearedDropdownMenu : OsuDropdown.OsuDropdownMenu + { + public ShearedDropdownMenu() + { + Shear = OsuGame.SHEAR; + Margin = new MarginPadding { Top = 5f }; + Padding = new MarginPadding + { + Left = -6f, + Right = 6f + }; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new ShearedMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; + + public partial class ShearedMenuItem : DrawableOsuDropdownMenuItem + { + public ShearedMenuItem(MenuItem item) + : base(item) + { + Foreground.Shear = -OsuGame.SHEAR; + } + } + } + + public partial class ShearedDropdownHeader : DropdownHeader + { + private LocalisableString label; + + protected override LocalisableString Label + { + get => label; + set + { + label = value; + valueText.Text = value; + } + } + + public LocalisableString LeftSideLabel + { + set => labelText.Text = value; + } + + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText valueText; + private readonly Box labelBox; + private readonly SpriteIcon chevron; + + public Container LabelContainer { get; } + + public ShearedDropdown Dropdown = null!; + private ShearedDropdownSearchBar searchBar = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public ShearedDropdownHeader() + { + Shear = OsuGame.SHEAR; + CornerRadius = ShearedButton.CORNER_RADIUS; + Masking = true; + + Foreground.Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new[] + { + LabelContainer = new Container + { + Depth = float.MaxValue, + CornerRadius = ShearedButton.CORNER_RADIUS, + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + labelBox = new Box + { + RelativeSizeAxes = Axes.Both + }, + labelText = new OsuSpriteText + { + Margin = new MarginPadding + { + Horizontal = 10f, + // Chosen specifically so the height of these dropdowns matches ShearedToggleButton (30). + Vertical = 7f + }, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Shear = -OsuGame.SHEAR, + }, + }, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10f }, + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + valueText = new TruncatingSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Right = 15f }, + Font = OsuFont.Style.Body, + RelativeSizeAxes = Axes.X, + }, + chevron = new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Y = 1f, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(10f), + } + }, + }, + } + } + }, + }; + + AddInternal(new HoverClickSounds()); + } + + [BackgroundDependencyLoader] + private void load() + { + labelBox.Colour = colourProvider.Background3; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Dropdown.Menu.StateChanged += _ => updateChevron(); + SearchBar.State.ValueChanged += _ => updateColour(); + Enabled.BindValueChanged(_ => updateColour()); + updateColour(); + } + + protected override void Update() + { + base.Update(); + searchBar.Padding = new MarginPadding { Left = LabelContainer.DrawWidth }; + + // By limiting the width we avoid this box showing up as an outline around the drawables that are on top of it. + Background.Padding = new MarginPadding { Left = LabelContainer.DrawWidth - ShearedButton.CORNER_RADIUS }; + } + + protected override bool OnHover(HoverEvent e) + { + updateColour(); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateColour(); + } + + private void updateColour() + { + bool hovered = Enabled.Value && IsHovered; + var hoveredColour = colourProvider.Light4; + var unhoveredColour = colourProvider.Background5; + + Colour = Enabled.Value ? Color4.White : OsuColour.Gray(0.6f); + + if (SearchBar.State.Value == Visibility.Visible) + { + chevron.Colour = hovered ? hoveredColour.Lighten(0.5f) : Colour4.White; + Background.Colour = unhoveredColour; + } + else + { + chevron.Colour = Color4.White; + Background.Colour = hovered ? hoveredColour : unhoveredColour; + } + } + + private void updateChevron() + { + Debug.Assert(Dropdown != null); + bool open = Dropdown.Menu.State == MenuState.Open; + chevron.ScaleTo(open ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); + } + + protected override DropdownSearchBar CreateSearchBar() => searchBar = new ShearedDropdownSearchBar(); + + private partial class ShearedDropdownSearchBar : DropdownSearchBar + { + protected override void PopIn() => this.FadeIn(); + + protected override void PopOut() => this.FadeOut(); + + protected override TextBox CreateTextBox() => new DropdownSearchTextBox + { + FontSize = OsuFont.Default.Size, + }; + + private partial class DropdownSearchTextBox : OsuTextBox + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? colourProvider) + { + TextContainer.Shear = -OsuGame.SHEAR; + BackgroundUnfocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + BackgroundFocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + BorderThickness = 0; + } + } + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs index 50d8d763e1..2fbe3ae89b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs @@ -32,6 +32,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 set => slider.Current = value; } + public CompositeDrawable TabbableContentContainer + { + set => textBox.TabbableContentContainer = value; + } + private bool instantaneous; /// @@ -69,6 +74,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 textBox = new LabelledTextBox { Label = labelText, + SelectAllOnFocus = true, }, slider = new SettingsSlider { @@ -87,8 +93,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true; - public bool SelectAll() => textBox.SelectAll(); - private bool updatingFromTextBox; private void textChanged(ValueChangedEvent change) diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index 6bb2a314e7..8b9ecc7462 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Text; using Microsoft.Toolkit.HighPerformance; +using osu.Framework.Extensions; using osu.Framework.IO.Stores; using SharpCompress.Archives.Zip; using SharpCompress.Common; @@ -54,12 +55,22 @@ namespace osu.Game.IO.Archives if (entry == null) return null; - var owner = MemoryAllocator.Default.Allocate((int)entry.Size); - using (Stream s = entry.OpenEntryStream()) - s.ReadExactly(owner.Memory.Span); + { + if (entry.Size > 0) + { + var owner = MemoryAllocator.Default.Allocate((int)entry.Size); + s.ReadExactly(owner.Memory.Span); + return new MemoryOwnerMemoryStream(owner); + } - return new MemoryOwnerMemoryStream(owner); + // due to a sharpcompress bug (https://github.com/adamhathcock/sharpcompress/issues/88), + // in rare instances the `ZipArchiveEntry` will not contain a correct `Size` but instead report 0. + // this would lead to the block above reading nothing, and the game basically seeing an archive full of empty files. + // since the bug is years old now, and this is a rather rare situation anyways (reported once in years), + // work around this locally by falling back to reading as many bytes as possible and using a standard non-pooled memory stream. + return new MemoryStream(s.ReadAllRemainingBytesToArray()); + } } public override void Dispose() diff --git a/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs b/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs deleted file mode 100644 index 8d14385707..0000000000 --- a/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.IO; - -namespace osu.Game.IO.FileAbstraction -{ - public class StreamFileAbstraction : TagLib.File.IFileAbstraction - { - public StreamFileAbstraction(string filename, Stream fileStream) - { - ReadStream = fileStream; - Name = filename; - } - - public string Name { get; } - - public Stream ReadStream { get; } - public Stream WriteStream => ReadStream; - - public void CloseStream(Stream stream) - { - ArgumentNullException.ThrowIfNull(stream); - - stream.Close(); - } - } -} diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index a936fa74da..27e1889c6a 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -61,6 +61,11 @@ namespace osu.Game.IO TryChangeToCustomStorage(out Error); } + /// + /// Returns the used for storing exported files. + /// + public virtual Storage GetExportStorage() => GetStorageForDirectory(@"exports"); + /// /// Resets the custom storage path, changing the target storage to the default location. /// diff --git a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs index de25d3e30e..19ef6b8fe6 100644 --- a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs +++ b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs @@ -62,8 +62,12 @@ namespace osu.Game.IO.Serialization.Converters if (tok["$type"] == null) throw new JsonException("Expected $type token."); - string typeName = lookupTable[(int)tok["$type"]]; - var instance = (T)Activator.CreateInstance(Type.GetType(typeName).AsNonNull())!; + // Prevent instantiation of types that do not inherit the type targetted by this converter + Type type = Type.GetType(lookupTable[(int)tok["$type"]]).AsNonNull(); + if (!type.IsAssignableTo(typeof(T))) + continue; + + var instance = (T)Activator.CreateInstance(type)!; serializer.Populate(itemReader, instance); list.Add(instance); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 170d247023..2aeb73d6c5 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -89,9 +89,6 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.Up, GlobalAction.SelectPrevious), new KeyBinding(InputKey.Down, GlobalAction.SelectNext), - new KeyBinding(InputKey.Left, GlobalAction.SelectPreviousGroup), - new KeyBinding(InputKey.Right, GlobalAction.SelectNextGroup), - new KeyBinding(InputKey.Space, GlobalAction.Select), new KeyBinding(InputKey.Enter, GlobalAction.Select), new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select), @@ -142,10 +139,9 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.EditorDecreaseDistanceSpacing), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing), - // Framework automatically converts wheel up/down to left/right when shift is held. - // See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/StateChanges/MouseScrollRelativeInput.cs#L37-L38. - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelRight }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor), + new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelDown }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), + new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelUp }, GlobalAction.EditorCycleNextBeatSnapDivisor), + new KeyBinding(InputKey.None, GlobalAction.EditorToggleMoveControl), new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl), new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl), new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject), @@ -156,6 +152,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.B }, GlobalAction.EditorRemoveClosestBookmark), new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.EditorSeekToPreviousBookmark), new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.EditorSeekToNextBookmark), + new KeyBinding(new[] { InputKey.Control, InputKey.L }, GlobalAction.EditorDiscardUnsavedChanges), }; private static IEnumerable editorTestPlayKeyBindings => new[] @@ -199,6 +196,14 @@ namespace osu.Game.Input.Bindings private static IEnumerable songSelectKeyBindings => new[] { + new KeyBinding(InputKey.Left, GlobalAction.ActivatePreviousSet), + new KeyBinding(InputKey.Right, GlobalAction.ActivateNextSet), + + new KeyBinding(new[] { InputKey.Shift, InputKey.Left }, GlobalAction.ExpandPreviousGroup), + new KeyBinding(new[] { InputKey.Shift, InputKey.Right }, GlobalAction.ExpandNextGroup), + + new KeyBinding(new[] { InputKey.Shift, InputKey.Enter }, GlobalAction.ToggleCurrentGroup), + new KeyBinding(InputKey.F1, GlobalAction.ToggleModSelection), new KeyBinding(InputKey.F2, GlobalAction.SelectNextRandom), new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.SelectPreviousRandom), @@ -206,6 +211,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.BackSpace, GlobalAction.DeselectAllMods), new KeyBinding(new[] { InputKey.Control, InputKey.Up }, GlobalAction.IncreaseModSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Down }, GlobalAction.DecreaseModSpeed), + new KeyBinding(InputKey.None, GlobalAction.AbsoluteScrollSongList), }; private static IEnumerable audioControlKeyBindings => new[] @@ -227,6 +233,10 @@ namespace osu.Game.Input.Bindings }; } + /// + /// IMPORTANT: New entries should always be added at the end of the enum, as key bindings are stored using the enum's numeric value and + /// changes in order would cause key bindings to get associated with the wrong action. + /// public enum GlobalAction { [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChat))] @@ -391,11 +401,11 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorDecreaseDistanceSpacing))] EditorDecreaseDistanceSpacing, - [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectPreviousGroup))] - SelectPreviousGroup, + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ActivatePreviousSet))] + ActivatePreviousSet, - [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectNextGroup))] - SelectNextGroup, + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ActivateNextSet))] + ActivateNextSet, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.DeselectAllMods))] DeselectAllMods, @@ -492,6 +502,24 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextBookmark))] EditorSeekToNextBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))] + AbsoluteScrollSongList, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleMoveControl))] + EditorToggleMoveControl, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorDiscardUnsavedChanges))] + EditorDiscardUnsavedChanges, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ExpandPreviousGroup))] + ExpandPreviousGroup, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ExpandNextGroup))] + ExpandNextGroup, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleCurrentGroup))] + ToggleCurrentGroup, } public enum GlobalActionCategory diff --git a/osu.Game/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs index 48ace58235..7209d3851b 100644 --- a/osu.Game/Input/RealmKeyBindingStore.cs +++ b/osu.Game/Input/RealmKeyBindingStore.cs @@ -5,8 +5,10 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Localisation; using osu.Game.Database; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Rulesets; using Realms; @@ -23,6 +25,19 @@ namespace osu.Game.Input this.keyCombinationProvider = keyCombinationProvider; } + /// + /// For a given , return a human-readable string representing the bindings bound to the action. + /// + public LocalisableString GetBindingsStringFor(GlobalAction globalAction) + { + var combinations = GetReadableKeyCombinationsFor(globalAction); + + if (combinations.Count == 0) + return ToastStrings.NoKeyBound; + + return string.Join(" / ", combinations); + } + /// /// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action. /// diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs index 89db60d8a6..37ebdd80e0 100644 --- a/osu.Game/Localisation/AudioSettingsStrings.cs +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -69,6 +69,11 @@ namespace osu.Game.Localisation /// public static LocalisableString SuggestedOffsetNote => new TranslatableString(getKey(@"suggested_offset_note"), @"Play a few beatmaps to receive a suggested offset!"); + /// + /// "Based on the last {0} play(s), your offset is set correctly!" + /// + public static LocalisableString SuggestedOffsetCorrect(int plays) => new TranslatableString(getKey(@"suggested_offset_correct"), @"Based on the last {0} play(s), your offset is set correctly!", plays); + /// /// "Based on the last {0} play(s), the suggested offset is {1} ms." /// @@ -84,6 +89,31 @@ namespace osu.Game.Localisation /// public static LocalisableString OffsetWizard => new TranslatableString(getKey(@"offset_wizard"), @"Offset wizard"); + /// + /// "Adjust beatmap offset automatically" + /// + public static LocalisableString AdjustBeatmapOffsetAutomatically => new TranslatableString(getKey(@"adjust_beatmap_offset_automatically"), @"Adjust beatmap offset automatically"); + + /// + /// "If enabled, the offset suggested from last play on a beatmap is automatically applied." + /// + public static LocalisableString AdjustBeatmapOffsetAutomaticallyTooltip => new TranslatableString(getKey(@"adjust_beatmap_offset_automatically_tooltip"), @"If enabled, the offset suggested from last play on a beatmap is automatically applied."); + + /// + /// "Use experimental audio mode" + /// + public static LocalisableString WasapiLabel => new TranslatableString(getKey(@"wasapi_label"), @"Use experimental audio mode"); + + /// + /// "This will attempt to initialise the audio engine in a lower latency mode." + /// + public static LocalisableString WasapiTooltip => new TranslatableString(getKey(@"wasapi_tooltip"), @"This will attempt to initialise the audio engine in a lower latency mode."); + + /// + /// "Due to reduced latency, your audio offset will need to be adjusted when enabling this setting. Generally expect to subtract 20 - 60 ms from your known value." + /// + public static LocalisableString WasapiNotice => new TranslatableString(getKey(@"wasapi_notice"), @"Due to reduced latency, your audio offset will need to be adjusted when enabling this setting. Generally expect to subtract 20 - 60 ms from your known value."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.cs b/osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.cs new file mode 100644 index 0000000000..68c1920a1b --- /dev/null +++ b/osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.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 class BeatmapLeaderboardWedgeStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BeatmapLeaderboardWedge"; + + /// + /// "Scope" + /// + public static LocalisableString Scope => new TranslatableString(getKey(@"scope"), @"Scope"); + + /// + /// "Local" + /// + public static LocalisableString Local => new TranslatableString(getKey(@"local"), @"Local"); + + /// + /// "Global" + /// + public static LocalisableString Global => new TranslatableString(getKey(@"global"), @"Global"); + + /// + /// "Country" + /// + public static LocalisableString Country => new TranslatableString(getKey(@"country"), @"Country"); + + /// + /// "Friend" + /// + public static LocalisableString Friend => new TranslatableString(getKey(@"friend"), @"Friend"); + + /// + /// "Team" + /// + public static LocalisableString Team => new TranslatableString(getKey(@"team"), @"Team"); + + /// + /// "Sort" + /// + public static LocalisableString Sort => new TranslatableString(getKey(@"sort"), @"Sort"); + + /// + /// "Score" + /// + public static LocalisableString Score => new TranslatableString(getKey(@"score"), @"Score"); + + /// + /// "Accuracy" + /// + public static LocalisableString Accuracy => new TranslatableString(getKey(@"accuracy"), @"Accuracy"); + + /// + /// "Max Combo" + /// + public static LocalisableString MaxCombo => new TranslatableString(getKey(@"max_combo"), @"Max Combo"); + + /// + /// "Misses" + /// + public static LocalisableString Misses => new TranslatableString(getKey(@"misses"), @"Misses"); + + /// + /// "Date" + /// + public static LocalisableString Date => new TranslatableString(getKey(@"date"), @"Date"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/BeatmapOverlayStrings.cs b/osu.Game/Localisation/BeatmapOverlayStrings.cs index fc818f7596..f8122c1ef9 100644 --- a/osu.Game/Localisation/BeatmapOverlayStrings.cs +++ b/osu.Game/Localisation/BeatmapOverlayStrings.cs @@ -28,6 +28,11 @@ This includes content that may not be correctly licensed for osu! usage. Browse /// public static LocalisableString UserContentConfirmButtonText => new TranslatableString(getKey(@"understood"), @"I understand"); + /// + /// "Featured Artists are music artists who have collaborated with osu! to make a selection of their tracks available for use in beatmaps. For some osu! releases, we showcase only featured artist beatmaps to better support the surrounding ecosystem." + /// + public static LocalisableString FeaturedArtistsTooltip => new TranslatableString(getKey(@"featured_artists_disabled_tooltip"), @"Featured Artists are music artists who have collaborated with osu! to make a selection of their tracks available for use in beatmaps. For some osu! releases, we showcase only featured artist beatmaps to better support the surrounding ecosystem."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/BeatmapStatisticStrings.cs b/osu.Game/Localisation/BeatmapStatisticStrings.cs new file mode 100644 index 0000000000..47cc153ac7 --- /dev/null +++ b/osu.Game/Localisation/BeatmapStatisticStrings.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 class BeatmapStatisticStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BeatmapStatisticStrings"; + + /// + /// "Circles" + /// + public static LocalisableString Circles => new TranslatableString(getKey(@"circles"), @"Circles"); + + /// + /// "Sliders" + /// + public static LocalisableString Sliders => new TranslatableString(getKey(@"sliders"), @"Sliders"); + + /// + /// "Spinners" + /// + public static LocalisableString Spinners => new TranslatableString(getKey(@"spinners"), @"Spinners"); + + /// + /// "Hits" + /// + public static LocalisableString Hits => new TranslatableString(getKey(@"hits"), @"Hits"); + + /// + /// "Drumrolls" + /// + public static LocalisableString Drumrolls => new TranslatableString(getKey(@"drumrolls"), @"Drumrolls"); + + /// + /// "Swells" + /// + public static LocalisableString Swells => new TranslatableString(getKey(@"swells"), @"Swells"); + + /// + /// "Fruits" + /// + public static LocalisableString Fruits => new TranslatableString(getKey(@"fruits"), @"Fruits"); + + /// + /// "Juice Streams" + /// + public static LocalisableString JuiceStreams => new TranslatableString(getKey(@"juice_streams"), @"Juice Streams"); + + /// + /// "Banana Showers" + /// + public static LocalisableString BananaShowers => new TranslatableString(getKey(@"banana_showers"), @"Banana Showers"); + + /// + /// "Notes" + /// + public static LocalisableString Notes => new TranslatableString(getKey(@"notes"), @"Notes"); + + /// + /// "Hold Notes" + /// + public static LocalisableString HoldNotes => new TranslatableString(getKey(@"hold_notes"), @"Hold Notes"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/BeatmapSubmissionStrings.cs b/osu.Game/Localisation/BeatmapSubmissionStrings.cs new file mode 100644 index 0000000000..0cf0498daa --- /dev/null +++ b/osu.Game/Localisation/BeatmapSubmissionStrings.cs @@ -0,0 +1,164 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class BeatmapSubmissionStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BeatmapSubmission"; + + /// + /// "Beatmap submission" + /// + public static LocalisableString BeatmapSubmissionTitle => new TranslatableString(getKey(@"beatmap_submission_title"), @"Beatmap submission"); + + /// + /// "Share your beatmap with the world!" + /// + public static LocalisableString BeatmapSubmissionDescription => new TranslatableString(getKey(@"beatmap_submission_description"), @"Share your beatmap with the world!"); + + /// + /// "Content permissions" + /// + public static LocalisableString ContentPermissions => new TranslatableString(getKey(@"content_permissions"), @"Content permissions"); + + /// + /// "I understand" + /// + public static LocalisableString ContentPermissionsAcknowledgement => new TranslatableString(getKey(@"content_permissions_acknowledgement"), @"I understand"); + + /// + /// "Frequently asked questions" + /// + public static LocalisableString FrequentlyAskedQuestions => new TranslatableString(getKey(@"frequently_asked_questions"), @"Frequently asked questions"); + + /// + /// "Submission settings" + /// + public static LocalisableString SubmissionSettings => new TranslatableString(getKey(@"submission_settings"), @"Submission settings"); + + /// + /// "Submit beatmap!" + /// + public static LocalisableString ConfirmSubmission => new TranslatableString(getKey(@"confirm_submission"), @"Submit beatmap!"); + + /// + /// "Exporting beatmap for compatibility..." + /// + public static LocalisableString Exporting => new TranslatableString(getKey(@"exporting"), @"Exporting beatmap for compatibility..."); + + /// + /// "Preparing for upload..." + /// + public static LocalisableString Preparing => new TranslatableString(getKey(@"preparing"), @"Preparing for upload..."); + + /// + /// "Uploading beatmap contents..." + /// + public static LocalisableString Uploading => new TranslatableString(getKey(@"uploading"), @"Uploading beatmap contents..."); + + /// + /// "Finishing up..." + /// + public static LocalisableString Finishing => new TranslatableString(getKey(@"finishing"), @"Finishing up..."); + + /// + /// "Before you continue, we ask you to check whether the content you are uploading has been cleared for upload. Please understand that you are responsible for the content you upload to the platform and if in doubt, should ask permission from the creators before uploading!" + /// + public static LocalisableString ContentPermissionsDisclaimer => new TranslatableString(getKey(@"content_permissions_disclaimer"), @"Before you continue, we ask you to check whether the content you are uploading has been cleared for upload. Please understand that you are responsible for the content you upload to the platform and if in doubt, should ask permission from the creators before uploading!"); + + /// + /// "Check the content usage guidelines for more information" + /// + public static LocalisableString CheckContentUsageGuidelines => new TranslatableString(getKey(@"check_content_usage_guidelines"), @"Check the content usage guidelines for more information"); + + /// + /// "Beatmap ranking criteria" + /// + public static LocalisableString BeatmapRankingCriteria => new TranslatableString(getKey(@"beatmap_ranking_criteria"), @"Beatmap ranking criteria"); + + /// + /// "Not sure you meet the guidelines? Check the list and speed up the ranking process!" + /// + public static LocalisableString BeatmapRankingCriteriaDescription => new TranslatableString(getKey(@"beatmap_ranking_criteria_description"), @"Not sure you meet the guidelines? Check the list and speed up the ranking process!"); + + /// + /// "Submission process" + /// + public static LocalisableString SubmissionProcess => new TranslatableString(getKey(@"submission_process"), @"Submission process"); + + /// + /// "Unsure about the submission process? Check out the wiki entry!" + /// + public static LocalisableString SubmissionProcessDescription => new TranslatableString(getKey(@"submission_process_description"), @"Unsure about the submission process? Check out the wiki entry!"); + + /// + /// "Mapping help forum" + /// + public static LocalisableString MappingHelpForum => new TranslatableString(getKey(@"mapping_help_forum"), @"Mapping help forum"); + + /// + /// "Got some questions about mapping and submission? Ask them in the forums!" + /// + public static LocalisableString MappingHelpForumDescription => new TranslatableString(getKey(@"mapping_help_forum_description"), @"Got some questions about mapping and submission? Ask them in the forums!"); + + /// + /// "Modding queues forum" + /// + public static LocalisableString ModdingQueuesForum => new TranslatableString(getKey(@"modding_queues_forum"), @"Modding queues forum"); + + /// + /// "Having trouble getting feedback? Why not ask in a mod queue!" + /// + public static LocalisableString ModdingQueuesForumDescription => new TranslatableString(getKey(@"modding_queues_forum_description"), @"Having trouble getting feedback? Why not ask in a mod queue!"); + + /// + /// "Where would you like to post your beatmap?" + /// + public static LocalisableString BeatmapSubmissionTargetCaption => new TranslatableString(getKey(@"beatmap_submission_target_caption"), @"Where would you like to post your beatmap?"); + + /// + /// "Works in Progress / Help (incomplete, not ready for ranking)" + /// + public static LocalisableString BeatmapSubmissionTargetWIP => new TranslatableString(getKey(@"beatmap_submission_target_wip"), @"Works in Progress / Help (incomplete, not ready for ranking)"); + + /// + /// "Pending (complete, ready for ranking)" + /// + public static LocalisableString BeatmapSubmissionTargetPending => new TranslatableString(getKey(@"beatmap_submission_target_pending"), @"Pending (complete, ready for ranking)"); + + /// + /// "Receive notifications for discussion replies" + /// + public static LocalisableString NotifyOnDiscussionReplies => new TranslatableString(getKey(@"notify_for_discussion_replies"), @"Receive notifications for discussion replies"); + + /// + /// "Load in browser after submission" + /// + public static LocalisableString LoadInBrowserAfterSubmission => new TranslatableString(getKey(@"load_in_browser_after_submission"), @"Load in browser after submission"); + + /// + /// "Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that this process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost." + /// + public static LocalisableString LegacyExportDisclaimer => new TranslatableString(getKey(@"legacy_export_disclaimer"), @"Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that this process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost."); + + /// + /// "Empty beatmaps cannot be submitted." + /// + public static LocalisableString EmptyBeatmapsCannotBeSubmitted => new TranslatableString(getKey(@"empty_beatmaps_cannot_be_submitted"), @"Empty beatmaps cannot be submitted."); + + /// + /// "Update beatmap!" + /// + public static LocalisableString UpdateBeatmap => new TranslatableString(getKey(@"update_beatmap"), @"Update beatmap!"); + + /// + /// "Upload NEW beatmap!" + /// + public static LocalisableString UploadNewBeatmap => new TranslatableString(getKey(@"upload_new_beatmap"), @"Upload NEW beatmap!"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/BreakInfoStrings.cs b/osu.Game/Localisation/BreakInfoStrings.cs new file mode 100644 index 0000000000..e327676e27 --- /dev/null +++ b/osu.Game/Localisation/BreakInfoStrings.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 class BreakInfoStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BreakInfo"; + + /// + /// "Current Progress" + /// + public static LocalisableString CurrentProgressTitle => new TranslatableString(getKey(@"current_progress_title"), @"Current Progress"); + + /// + /// "Grade" + /// + public static LocalisableString ShowInfoGrade => new TranslatableString(getKey(@"show_info_grade"), @"Grade"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/ButtonSystemStrings.cs b/osu.Game/Localisation/ButtonSystemStrings.cs index b0a205eebe..a9bc3068da 100644 --- a/osu.Game/Localisation/ButtonSystemStrings.cs +++ b/osu.Game/Localisation/ButtonSystemStrings.cs @@ -59,6 +59,25 @@ namespace osu.Game.Localisation /// public static LocalisableString DailyChallenge => new TranslatableString(getKey(@"daily_challenge"), @"daily challenge"); + /// + /// "A few important words from your dev team!" + /// + public static LocalisableString MobileDisclaimerHeader => new TranslatableString(getKey(@"mobile_disclaimer_header"), @"A few important words from your dev team!"); + + /// + /// "While we have released osu! on mobile platforms to maximise the number of people that can enjoy the game, our focus is still on the PC version. + /// + /// Your experience will not be perfect, and may even feel subpar compared to games which are made mobile-first. + /// + /// Please bear with us as we continue to improve the game for you!" + /// + public static LocalisableString MobileDisclaimerBody => new TranslatableString(getKey(@"mobile_disclaimer_body"), + @"While we have released osu! on mobile platforms to maximise the number of people that can enjoy the game, our focus is still on the PC version. + +Your experience will not be perfect, and may even feel subpar compared to games which are made mobile-first. + +Please bear with us as we continue to improve the game for you!"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ChatStrings.cs b/osu.Game/Localisation/ChatStrings.cs index 6841e7d938..b14cfd6729 100644 --- a/osu.Game/Localisation/ChatStrings.cs +++ b/osu.Game/Localisation/ChatStrings.cs @@ -25,9 +25,9 @@ namespace osu.Game.Localisation public static LocalisableString MentionUser => new TranslatableString(getKey(@"mention_user"), @"Mention"); /// - /// "press enter to chat..." + /// "press {0} to chat..." /// - public static LocalisableString InGameInputPlaceholder => new TranslatableString(getKey(@"in_game_input_placeholder"), @"press enter to chat..."); + public static LocalisableString InGameInputPlaceholder(LocalisableString keyBind) => new TranslatableString(getKey(@"in_game_input_placeholder"), @"press {0} to chat...", keyBind); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/CollectionsStrings.cs b/osu.Game/Localisation/CollectionsStrings.cs new file mode 100644 index 0000000000..f096dd9570 --- /dev/null +++ b/osu.Game/Localisation/CollectionsStrings.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 class CollectionsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Collections"; + + /// + /// "Manage Collections" + /// + public static LocalisableString ManageCollectionsTitle => new TranslatableString(getKey(@"manage_collections_title"), @"Manage Collections"); + + /// + /// "Collection" + /// + public static LocalisableString Collection => new TranslatableString(getKey(@"collection"), @"Collection"); + + /// + /// "All beatmaps" + /// + public static LocalisableString AllBeatmaps => new TranslatableString(getKey(@"all_beatmaps"), @"All beatmaps"); + + /// + /// "Manage collections..." + /// + public static LocalisableString ManageCollections => new TranslatableString(getKey(@"manage_collections"), @"Manage collections..."); + + /// + /// "Create a new collection" + /// + public static LocalisableString CreateNew => new TranslatableString(getKey(@"create_new"), @"Create a new collection"); + + /// + /// "Remove selected beatmap" + /// + public static LocalisableString RemoveSelectedBeatmap => new TranslatableString(getKey(@"remove_selected_beatmap"), @"Remove selected beatmap"); + + /// + /// "Add selected beatmap" + /// + public static LocalisableString AddSelectedBeatmap => new TranslatableString(getKey(@"add_selected_beatmap"), @"Add selected beatmap"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 243a100029..c8630f9332 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -39,6 +39,11 @@ namespace osu.Game.Localisation /// public static LocalisableString Default => new TranslatableString(getKey(@"default"), @"Default"); + /// + /// "Rename" + /// + public static LocalisableString Rename => new TranslatableString(getKey(@"rename"), @"Rename"); + /// /// "Export" /// @@ -179,6 +184,21 @@ namespace osu.Game.Localisation /// public static LocalisableString CopyLink => new TranslatableString(getKey(@"copy_link"), @"Copy link"); + /// + /// "Manage..." + /// + public static LocalisableString Manage => new TranslatableString(getKey(@"manage"), @"Manage..."); + + /// + /// "Details..." + /// + public static LocalisableString Details => new TranslatableString(getKey(@"details"), @"Details..."); + + /// + /// "Mapper" + /// + public static LocalisableString Mapper => new TranslatableString(getKey(@"mapper"), @"Mapper"); + private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/ContextMenuStrings.cs b/osu.Game/Localisation/ContextMenuStrings.cs index cb18a2159c..b2ca941287 100644 --- a/osu.Game/Localisation/ContextMenuStrings.cs +++ b/osu.Game/Localisation/ContextMenuStrings.cs @@ -29,6 +29,16 @@ namespace osu.Game.Localisation /// public static LocalisableString SpectatePlayer => new TranslatableString(getKey(@"spectate_player"), @"Spectate"); + /// + /// "Are you sure you want to block {0}?" + /// + public static LocalisableString ConfirmBlockUser(string username) => new TranslatableString(getKey(@"confirm_block_user"), @"Are you sure you want to block {0}?", username); + + /// + /// "Are you sure you want to unblock {0}?" + /// + public static LocalisableString ConfirmUnblockUser(string username) => new TranslatableString(getKey(@"confirm_unblock_user"), @"Are you sure you want to unblock {0}?", username); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/DefaultRankDisplayStrings.cs b/osu.Game/Localisation/DefaultRankDisplayStrings.cs new file mode 100644 index 0000000000..88e3b4309a --- /dev/null +++ b/osu.Game/Localisation/DefaultRankDisplayStrings.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 DefaultRankDisplayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DefaultRankDisplay"; + + /// + /// "Play samples on rank change" + /// + public static LocalisableString PlaySamplesOnRankChange => new TranslatableString(getKey(@"play_samples_on_rank_change"), @"Play samples on rank change"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/DrawableRoomPlaylistItemStrings.cs b/osu.Game/Localisation/DrawableRoomPlaylistItemStrings.cs new file mode 100644 index 0000000000..44616c03ca --- /dev/null +++ b/osu.Game/Localisation/DrawableRoomPlaylistItemStrings.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 DrawableRoomPlaylistItemStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DrawableRoomPlaylistItem"; + + /// + /// "You have completed this beatmap" + /// + public static LocalisableString CompletedTooltip => new TranslatableString(getKey(@"completed_tooltip"), @"You have completed this beatmap"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/EditorDialogsStrings.cs b/osu.Game/Localisation/EditorDialogsStrings.cs index 94f28c617c..3617dca81f 100644 --- a/osu.Game/Localisation/EditorDialogsStrings.cs +++ b/osu.Game/Localisation/EditorDialogsStrings.cs @@ -54,6 +54,11 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorReloadDialogHeader => new TranslatableString(getKey(@"editor_reload_dialog_header"), @"The editor must be reloaded to apply this change. The beatmap will be saved."); + /// + /// "Discard all unsaved changes? This cannot be undone." + /// + public static LocalisableString DiscardUnsavedChangesDialogHeader => new TranslatableString(getKey(@"discard_unsaved_changes_dialog_header"), @"Discard all unsaved changes? This cannot be undone."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/EditorSetupStrings.cs b/osu.Game/Localisation/EditorSetupStrings.cs index 350517734f..8597b7d9a1 100644 --- a/osu.Game/Localisation/EditorSetupStrings.cs +++ b/osu.Game/Localisation/EditorSetupStrings.cs @@ -198,6 +198,21 @@ namespace osu.Game.Localisation /// public static LocalisableString ClickToSelectBackground => new TranslatableString(getKey(@"click_to_select_background"), @"Click to select a background image"); + /// + /// "Apply this change to all difficulties?" + /// + public static LocalisableString ApplicationScopeSelectionTitle => new TranslatableString(getKey(@"application_scope_selection_title"), @"Apply this change to all difficulties?"); + + /// + /// "Apply to all difficulties" + /// + public static LocalisableString ApplyToAllDifficulties => new TranslatableString(getKey(@"apply_to_all_difficulties"), @"Apply to all difficulties"); + + /// + /// "Only apply to this difficulty" + /// + public static LocalisableString ApplyToThisDifficulty => new TranslatableString(getKey(@"apply_to_this_difficulty"), @"Only apply to this difficulty"); + /// /// "Ruleset ({0})" /// diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index 3b4026be11..c8b163c678 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -69,6 +69,16 @@ namespace osu.Game.Localisation /// public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty"); + /// + /// "Edit externally" + /// + public static LocalisableString EditExternally => new TranslatableString(getKey(@"edit_externally"), @"Edit externally"); + + /// + /// "Submit beatmap" + /// + public static LocalisableString SubmitBeatmap => new TranslatableString(getKey(@"submit_beatmap"), @"Submit beatmap"); + /// /// "setup" /// @@ -144,6 +154,11 @@ namespace osu.Game.Localisation /// public static LocalisableString TimelineShowTimingChanges => new TranslatableString(getKey(@"timeline_show_timing_changes"), @"Show timing changes"); + /// + /// "Finish editing and import changes" + /// + public static LocalisableString FinishEditingExternally => new TranslatableString(getKey(@"Finish editing and import changes"), @"Finish editing and import changes"); + /// /// "Show breaks" /// @@ -184,6 +199,26 @@ namespace osu.Game.Localisation /// public static LocalisableString ResetBookmarks => new TranslatableString(getKey(@"reset_bookmarks"), @"Reset bookmarks"); + /// + /// "Open beatmap info page" + /// + public static LocalisableString OpenInfoPage => new TranslatableString(getKey(@"open_info_page"), @"Open beatmap info page"); + + /// + /// "Open beatmap discussion page" + /// + public static LocalisableString OpenDiscussionPage => new TranslatableString(getKey(@"open_discussion_page"), @"Open beatmap discussion page"); + + /// + /// "Current difficulty" + /// + public static LocalisableString CheckCurrentDifficulty => new TranslatableString(getKey(@"check_current_difficulty"), @"Current difficulty"); + + /// + /// "Entire beatmap set" + /// + public static LocalisableString CheckEntireBeatmapSet => new TranslatableString(getKey(@"check_entire_beatmap_set"), @"Entire beatmap set"); + private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs index 521a77fe20..6293a4f840 100644 --- a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs +++ b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs @@ -15,10 +15,9 @@ namespace osu.Game.Localisation public static LocalisableString Header => new TranslatableString(getKey(@"header"), @"Import"); /// - /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way." + /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way." /// - public static LocalisableString Description => new TranslatableString(getKey(@"description"), - @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way."); + public static LocalisableString Description => new TranslatableString(getKey(@"description"), @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way."); /// /// "previous osu! install" @@ -38,8 +37,7 @@ namespace osu.Game.Localisation /// /// "Your import will continue in the background. Check on its progress in the notifications sidebar!" /// - public static LocalisableString ImportInProgress => - new TranslatableString(getKey(@"import_in_progress"), @"Your import will continue in the background. Check on its progress in the notifications sidebar!"); + public static LocalisableString ImportInProgress => new TranslatableString(getKey(@"import_in_progress"), @"Your import will continue in the background. Check on its progress in the notifications sidebar!"); /// /// "calculating..." diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 83a3af574c..20db5983fd 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -79,11 +79,26 @@ namespace osu.Game.Localisation /// public static LocalisableString LearnMoreAboutLazerTooltip => new TranslatableString(getKey(@"check_out_the_feature_comparison"), @"Check out the feature comparison and FAQ"); + /// + /// "Check with your package manager / provider for other release streams." + /// + public static LocalisableString ChangeReleaseStreamPackageManagerWarning => new TranslatableString(getKey(@"change_release_stream_package_warning"), @"Check with your package manager / provider for other release streams."); + + /// + /// "Are you sure you want to run a potentially unstable version of the game?" + /// + public static LocalisableString ChangeReleaseStreamConfirmation => new TranslatableString(getKey(@"change_release_stream_confirmation"), @"Are you sure you want to run a potentially unstable version of the game?"); + + /// + /// "If you run into issues starting the game, you can usually run the installer from the official site to recover." + /// + public static LocalisableString ChangeReleaseStreamConfirmationInfo => new TranslatableString(getKey(@"change_release_stream_confirmation_info"), @"If you run into issues starting the game, you can usually run the installer from the official site to recover."); + /// /// "You are running the latest release ({0})" /// public static LocalisableString RunningLatestRelease(string version) => new TranslatableString(getKey(@"running_latest_release"), @"You are running the latest release ({0})", version); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index f9db0461ce..8536249d35 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -130,14 +130,29 @@ namespace osu.Game.Localisation public static LocalisableString SelectNext => new TranslatableString(getKey(@"select_next"), @"Next selection"); /// - /// "Select previous group" + /// "Activate previous set" /// - public static LocalisableString SelectPreviousGroup => new TranslatableString(getKey(@"select_previous_group"), @"Select previous group"); + public static LocalisableString ActivatePreviousSet => new TranslatableString(getKey(@"activate_previous_set"), @"Activate previous set"); /// - /// "Select next group" + /// "Activate next set" /// - public static LocalisableString SelectNextGroup => new TranslatableString(getKey(@"select_next_group"), @"Select next group"); + public static LocalisableString ActivateNextSet => new TranslatableString(getKey(@"activate_next_set"), @"Activate next set"); + + /// + /// "Expand previous group" + /// + public static LocalisableString ExpandPreviousGroup => new TranslatableString(getKey(@"expand_previous_group"), @"Expand previous group"); + + /// + /// "Expand next group" + /// + public static LocalisableString ExpandNextGroup => new TranslatableString(getKey(@"expand_next_group"), @"Expand next group"); + + /// + /// "Toggle expansion of current group" + /// + public static LocalisableString ToggleCurrentGroup => new TranslatableString(getKey(@"toggle_current_group"), @"Toggle expansion of current group"); /// /// "Home" @@ -330,9 +345,9 @@ namespace osu.Game.Localisation public static LocalisableString SeekReplayBackward => new TranslatableString(getKey(@"seek_replay_backward"), @"Seek replay backward"); /// - /// "Seek replay forward one frame" + /// "Step replay forward one frame" /// - public static LocalisableString StepReplayForward => new TranslatableString(getKey(@"step_replay_forward"), @"Seek replay forward one frame"); + public static LocalisableString StepReplayForward => new TranslatableString(getKey(@"step_replay_forward"), @"Step replay forward one frame"); /// /// "Step replay backward one frame" @@ -449,6 +464,21 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorSeekToNextBookmark => new TranslatableString(getKey(@"editor_seek_to_next_bookmark"), @"Seek to next bookmark"); + /// + /// "Absolute scroll song list" + /// + public static LocalisableString AbsoluteScrollSongList => new TranslatableString(getKey(@"absolute_scroll_song_list"), @"Absolute scroll song list"); + + /// + /// "Toggle movement control" + /// + public static LocalisableString EditorToggleMoveControl => new TranslatableString(getKey(@"editor_toggle_move_control"), @"Toggle movement control"); + + /// + /// "Discard unsaved changes" + /// + public static LocalisableString EditorDiscardUnsavedChanges => new TranslatableString(getKey(@"editor_discard_unsaved_changes"), @"Discard unsaved changes"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/HUD/AimErrorMeterStrings.cs b/osu.Game/Localisation/HUD/AimErrorMeterStrings.cs new file mode 100644 index 0000000000..31d81d41e3 --- /dev/null +++ b/osu.Game/Localisation/HUD/AimErrorMeterStrings.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.HUD +{ + public static class AimErrorMeterStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.HUD.AimErrorMeterStrings"; + + /// + /// "Hit marker size" + /// + public static LocalisableString HitMarkerSize => new TranslatableString(getKey(@"hit_marker_size"), @"Hit marker size"); + + /// + /// "Controls the size of the markers displayed after every hit." + /// + public static LocalisableString HitMarkerSizeDescription => new TranslatableString(getKey(@"hit_marker_size_description"), @"Controls the size of the markers displayed after every hit."); + + /// + /// "Hit marker style" + /// + public static LocalisableString HitMarkerStyle => new TranslatableString(getKey(@"hit_marker_style"), @"Hit marker style"); + + /// + /// "The visual style of the hit markers." + /// + public static LocalisableString HitMarkerStyleDescription => new TranslatableString(getKey(@"hit_marker_style_description"), @"The visual style of the hit markers."); + + /// + /// "Average position marker size" + /// + public static LocalisableString AverageMarkerSize => new TranslatableString(getKey(@"average_marker_size"), @"Average position marker size"); + + /// + /// "Controls the size of the marker showing average hit position." + /// + public static LocalisableString AverageMarkerSizeDescription => new TranslatableString(getKey(@"average_marker_size_description"), @"Controls the size of the marker showing average hit position."); + + /// + /// "Average position marker style" + /// + public static LocalisableString AverageMarkerStyle => new TranslatableString(getKey(@"average_marker_style"), @"Average position marker style"); + + /// + /// "The visual style of the average position marker." + /// + public static LocalisableString AverageMarkerStyleDescription => new TranslatableString(getKey(@"average_marker_style_description"), @"The visual style of the average position marker."); + + /// + /// "Position display style" + /// + public static LocalisableString PositionDisplayStyle => new TranslatableString(getKey(@"position_style"), @"Position display style"); + + /// + /// "Controls whether positions displayed on the meter are absolute (as seen on screen) or normalised (relative to the direction of movement from previous object)." + /// + public static LocalisableString PositionDisplayStyleDescription => new TranslatableString(getKey(@"position_style_description"), @"Controls whether positions displayed on the meter are absolute (as seen on screen) or normalised (relative to the direction of movement from previous object)."); + + /// + /// "Absolute" + /// + public static LocalisableString Absolute => new TranslatableString(getKey(@"absolute"), @"Absolute"); + + /// + /// "Normalised" + /// + public static LocalisableString Normalised => new TranslatableString(getKey(@"normalised"), @"Normalised"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/HUD/SpectatorListStrings.cs b/osu.Game/Localisation/HUD/SpectatorListStrings.cs new file mode 100644 index 0000000000..8d82250526 --- /dev/null +++ b/osu.Game/Localisation/HUD/SpectatorListStrings.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.HUD +{ + public static class SpectatorListStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SpectatorList"; + + /// + /// "Spectators ({0})" + /// + public static LocalisableString SpectatorCount(int arg0) => new TranslatableString(getKey(@"spectator_count"), @"Spectators ({0})", arg0); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/LeaderboardStrings.cs b/osu.Game/Localisation/LeaderboardStrings.cs index 8e53f8e88c..816406bf31 100644 --- a/osu.Game/Localisation/LeaderboardStrings.cs +++ b/osu.Game/Localisation/LeaderboardStrings.cs @@ -44,6 +44,11 @@ namespace osu.Game.Localisation /// public static LocalisableString PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard => new TranslatableString(getKey(@"please_invest_in_an_osu_supporter_tag_to_view_this_leaderboard"), @"Please invest in an osu!supporter tag to view this leaderboard!"); + /// + /// "You are not on a team. Maybe you should join one!" + /// + public static LocalisableString NoTeam => new TranslatableString(getKey(@"no_team"), @"You are not on a team. Maybe you should join one!"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MenuTipStrings.cs b/osu.Game/Localisation/MenuTipStrings.cs index f97ad5fa2c..ebab9f4d02 100644 --- a/osu.Game/Localisation/MenuTipStrings.cs +++ b/osu.Game/Localisation/MenuTipStrings.cs @@ -10,14 +10,14 @@ namespace osu.Game.Localisation private const string prefix = @"osu.Game.Resources.Localisation.MenuTip"; /// - /// "Press Ctrl-T anywhere in the game to toggle the toolbar!" + /// "Press {0} anywhere in the game to toggle the toolbar!" /// - public static LocalisableString ToggleToolbarShortcut => new TranslatableString(getKey(@"toggle_toolbar_shortcut"), @"Press Ctrl-T anywhere in the game to toggle the toolbar!"); + public static LocalisableString ToggleToolbarShortcut(LocalisableString keybind) => new TranslatableString(getKey(@"toggle_toolbar_shortcut"), @"Press {0} anywhere in the game to toggle the toolbar!", keybind); /// - /// "Press Ctrl-O anywhere in the game to access settings!" + /// "Press {0} anywhere in the game to access settings!" /// - public static LocalisableString GameSettingsShortcut => new TranslatableString(getKey(@"game_settings_shortcut"), @"Press Ctrl-O anywhere in the game to access settings!"); + public static LocalisableString GameSettingsShortcut(LocalisableString keybind) => new TranslatableString(getKey(@"game_settings_shortcut"), @"Press {0} anywhere in the game to access settings!", keybind); /// /// "All settings are dynamic and take effect in real-time. Try changing the skin while watching autoplay!" @@ -40,9 +40,9 @@ namespace osu.Game.Localisation public static LocalisableString ScreenScalingSettings => new TranslatableString(getKey(@"screen_scaling_settings"), @"Try adjusting the ""Screen Scaling"" mode to change your gameplay or UI area, even in fullscreen!"); /// - /// "What used to be "osu!direct" is available to all users just like on the website. You can access it anywhere using Ctrl-B!" + /// "What used to be "osu!direct" is available to all users just like on the website. You can access it anywhere using {0}!" /// - public static LocalisableString FreeOsuDirect => new TranslatableString(getKey(@"free_osu_direct"), @"What used to be ""osu!direct"" is available to all users just like on the website. You can access it anywhere using Ctrl-B!"); + public static LocalisableString FreeOsuDirect(LocalisableString keybind) => new TranslatableString(getKey(@"free_osu_direct"), @"What used to be ""osu!direct"" is available to all users just like on the website. You can access it anywhere using {0}!", keybind); /// /// "Seeking in replays is available by dragging on the progress bar at the bottom of the screen or by using the left and right arrow keys!" @@ -75,9 +75,9 @@ namespace osu.Game.Localisation public static LocalisableString ToggleAdvancedFPSCounter => new TranslatableString(getKey(@"toggle_advanced_fps_counter"), @"Toggle advanced frame / thread statistics with Ctrl-F11!"); /// - /// "You can pause during a replay by pressing Space!" + /// "You can pause during a replay by pressing {0}!" /// - public static LocalisableString ReplayPausing => new TranslatableString(getKey(@"replay_pausing"), @"You can pause during a replay by pressing Space!"); + public static LocalisableString ReplayPausing(LocalisableString keybind) => new TranslatableString(getKey(@"replay_pausing"), @"You can pause during a replay by pressing {0}!", keybind); /// /// "Most of the hotkeys in the game are configurable and can be changed to anything you want. Check the bindings panel under input settings!" @@ -85,9 +85,9 @@ namespace osu.Game.Localisation public static LocalisableString ConfigurableHotkeys => new TranslatableString(getKey(@"configurable_hotkeys"), @"Most of the hotkeys in the game are configurable and can be changed to anything you want. Check the bindings panel under input settings!"); /// - /// "Your gameplay HUD can be customised by using the skin layout editor. Open it at any time via Ctrl-Shift-S!" + /// "Your gameplay HUD can be customised by using the skin layout editor. Open it at any time via {0}!" /// - public static LocalisableString SkinEditor => new TranslatableString(getKey(@"skin_editor"), @"Your gameplay HUD can be customised by using the skin layout editor. Open it at any time via Ctrl-Shift-S!"); + public static LocalisableString SkinEditor(LocalisableString keybind) => new TranslatableString(getKey(@"skin_editor"), @"Your gameplay HUD can be customised by using the skin layout editor. Open it at any time via {0}!", keybind); /// /// "You can create mod presets to make toggling your favourite mod combinations easier!" @@ -100,14 +100,14 @@ namespace osu.Game.Localisation public static LocalisableString ModCustomisationSettings => new TranslatableString(getKey(@"mod_customisation_settings"), @"Many mods have customisation settings that drastically change how they function. Click the Customise button in mod select to view settings!"); /// - /// "Press Ctrl-Shift-R to switch to a random skin!" + /// "Press {0} to switch to a random skin!" /// - public static LocalisableString RandomSkinShortcut => new TranslatableString(getKey(@"random_skin_shortcut"), @"Press Ctrl-Shift-R to switch to a random skin!"); + public static LocalisableString RandomSkinShortcut(LocalisableString keybind) => new TranslatableString(getKey(@"random_skin_shortcut"), @"Press {0} to switch to a random skin!", keybind); /// - /// "While watching a replay, press Ctrl-H to toggle replay settings!" + /// "While watching a replay, press {0} to toggle replay settings!" /// - public static LocalisableString ToggleReplaySettingsShortcut => new TranslatableString(getKey(@"toggle_replay_settings_shortcut"), @"While watching a replay, press Ctrl-H to toggle replay settings!"); + public static LocalisableString ToggleReplaySettingsShortcut(LocalisableString keybind) => new TranslatableString(getKey(@"toggle_replay_settings_shortcut"), @"While watching a replay, press {0} to toggle replay settings!", keybind); /// /// "You can easily copy the mods from scores on a leaderboard by right-clicking on them!" @@ -119,6 +119,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AutoplayBeatmapShortcut => new TranslatableString(getKey(@"autoplay_beatmap_shortcut"), @"Ctrl-Enter at song select will start a beatmap in autoplay mode!"); + /// + /// ""Lazer" is not an English word. The correct spelling for the bright light is "laser"." + /// + public static LocalisableString LazerIsNotAWord => new TranslatableString(getKey(@"lazer_is_not_a_word"), @"""Lazer"" is not an English word. The correct spelling for the bright light is ""laser""."); + /// /// "Multithreading support means that even with low "FPS" your input and judgements will be accurate!" /// @@ -135,15 +140,25 @@ namespace osu.Game.Localisation public static LocalisableString GlobalStatisticsShortcut => new TranslatableString(getKey(@"global_statistics_shortcut"), @"Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!"); /// - /// "When your gameplay HUD is hidden, you can press and hold Ctrl to view it temporarily!" + /// "When your gameplay HUD is hidden, you can press and hold {0} to view it temporarily!" /// - public static LocalisableString PeekHUDWhenHidden => new TranslatableString(getKey(@"peek_hud_when_hidden"), @"When your gameplay HUD is hidden, you can press and hold Ctrl to view it temporarily!"); + public static LocalisableString PeekHUDWhenHidden(LocalisableString keybind) => new TranslatableString(getKey(@"peek_hud_when_hidden"), @"When your gameplay HUD is hidden, you can press and hold {0} to view it temporarily!", keybind); /// /// "Drag and drop any image into the skin editor to load it in quickly!" /// public static LocalisableString DragAndDropImageInSkinEditor => new TranslatableString(getKey(@"drag_and_drop_image_in_skin_editor"), @"Drag and drop any image into the skin editor to load it in quickly!"); + /// + /// "Try holding your right mouse button near the beatmap carousel to quickly scroll to an absolute position!" + /// + public static LocalisableString RightMouseAbsoluteScroll => new TranslatableString(getKey(@"right_mouse_absolute_scroll"), @"Try holding your right mouse button near the beatmap carousel to quickly scroll to an absolute position!"); + + /// + /// "Shift-click on a beatmap panel in the beatmap listing overlay to quickly download or view the beatmap in song select!" + /// + public static LocalisableString ShiftClickInBeatmapOverlay => new TranslatableString(getKey(@"shift_click_in_beatmap_overlay"), @"Shift-click on a beatmap panel in the beatmap listing overlay to quickly download or view the beatmap in song select!"); + /// /// "a tip for you:" /// diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs index e61af07364..c92c3b6ddc 100644 --- a/osu.Game/Localisation/MouseSettingsStrings.cs +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -25,9 +25,9 @@ namespace osu.Game.Localisation 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"." + /// "Attempts to bypass any operating 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""."); + public static LocalisableString HighPrecisionMouseTooltip => new TranslatableString(getKey(@"high_precision_mouse_tooltip"), @"Attempts to bypass any operating system mouse acceleration. On Windows, this is equivalent to what used to be known as ""Raw Input""."); /// /// "Confine mouse cursor to window" diff --git a/osu.Game/Localisation/MultiplayerMatchStrings.cs b/osu.Game/Localisation/MultiplayerMatchStrings.cs index 95c7168a09..8c9e76d722 100644 --- a/osu.Game/Localisation/MultiplayerMatchStrings.cs +++ b/osu.Game/Localisation/MultiplayerMatchStrings.cs @@ -24,6 +24,21 @@ namespace osu.Game.Localisation /// public static LocalisableString StartMatchWithCountdown(string humanReadableTime) => new TranslatableString(getKey(@"start_match_width_countdown"), @"Start match in {0}", humanReadableTime); + /// + /// "Choose the mods which all players should play with." + /// + public static LocalisableString RequiredModsButtonTooltip => new TranslatableString(getKey(@"required_mods_button_tooltip"), @"Choose the mods which all players should play with."); + + /// + /// "Each player can choose their preferred mods from a selected list." + /// + public static LocalisableString FreeModsButtonTooltip => new TranslatableString(getKey(@"free_mods_button_tooltip"), @"Each player can choose their preferred mods from a selected list."); + + /// + /// "Each player can choose their preferred difficulty, ruleset and mods." + /// + public static LocalisableString FreestyleButtonTooltip => new TranslatableString(getKey(@"freestyle_button_tooltip"), @"Each player can choose their preferred difficulty, ruleset and mods."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index d8f768f2d8..66250d1629 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -83,16 +83,6 @@ Please try changing your audio device to a working setting."); /// public static LocalisableString LinkTypeNotSupported => new TranslatableString(getKey(@"unsupported_link_type"), @"This link type is not yet supported!"); - /// - /// "You received a private message from '{0}'. Click to read it!" - /// - public static LocalisableString PrivateMessageReceived(string username) => new TranslatableString(getKey(@"private_message_received"), @"You received a private message from '{0}'. Click to read it!", username); - - /// - /// "Your name was mentioned in chat by '{0}'. Click to find out why!" - /// - public static LocalisableString YourNameWasMentioned(string username) => new TranslatableString(getKey(@"your_name_was_mentioned"), @"Your name was mentioned in chat by '{0}'. Click to find out why!", username); - /// /// "{0} invited you to the multiplayer match "{1}"! Click to join." /// @@ -115,7 +105,7 @@ Please try changing your audio device to a working setting."); /// /// "You are now running osu! {0}. - /// Click to see what's new!" + /// Click to see what's new!" /// public static LocalisableString GameVersionAfterUpdate(string version) => new TranslatableString(getKey(@"game_version_after_update"), @"You are now running osu! {0}. Click to see what's new!", version); @@ -135,6 +125,16 @@ Click to see what's new!", version); /// public static LocalisableString DownloadingUpdate => new TranslatableString(getKey(@"downloading_update"), @"Downloading update..."); + /// + /// "This multiplayer room has ended. Click to display room results." + /// + public static LocalisableString MultiplayerRoomEnded => new TranslatableString(getKey(@"multiplayer_room_ended"), @"This multiplayer room has ended. Click to display room results."); + + /// + /// "Mention" + /// + public static LocalisableString Mention => new TranslatableString(getKey(@"mention"), @"Mention"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 8e8c81cf59..98364a3f5a 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -29,6 +29,16 @@ namespace osu.Game.Localisation /// public static LocalisableString NotifyOnPrivateMessage => new TranslatableString(getKey(@"notify_on_private_message"), @"Show a notification when you receive a private message"); + /// + /// "Show notification popups when friends change status" + /// + public static LocalisableString NotifyOnFriendPresenceChange => new TranslatableString(getKey(@"notify_on_friend_presence_change"), @"Show notification popups when friends change status"); + + /// + /// "Notifications will be shown when friends go online/offline." + /// + public static LocalisableString NotifyOnFriendPresenceChangeTooltip => new TranslatableString(getKey(@"notify_on_friend_presence_change_tooltip"), @"Notifications will be shown when friends go online/offline."); + /// /// "Integrations" /// @@ -84,6 +94,6 @@ namespace osu.Game.Localisation /// public static LocalisableString HideCountryFlags => new TranslatableString(getKey(@"hide_country_flags"), @"Hide country flags"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/PlayerSettingsOverlayStrings.cs b/osu.Game/Localisation/PlayerSettingsOverlayStrings.cs index 60874da561..d659f950dc 100644 --- a/osu.Game/Localisation/PlayerSettingsOverlayStrings.cs +++ b/osu.Game/Localisation/PlayerSettingsOverlayStrings.cs @@ -29,6 +29,61 @@ namespace osu.Game.Localisation /// public static LocalisableString SeekForwardSeconds(double arg0) => new TranslatableString(getKey(@"seek_forward_seconds"), @"Seek forward {0} seconds", arg0); + /// + /// "Playback speed" + /// + public static LocalisableString PlaybackSpeed => new TranslatableString(getKey(@"playback_speed"), @"Playback speed"); + + /// + /// "Show click markers" + /// + public static LocalisableString ShowClickMarkers => new TranslatableString(getKey(@"show_click_markers"), @"Show click markers"); + + /// + /// "Show frame markers" + /// + public static LocalisableString ShowFrameMarkers => new TranslatableString(getKey(@"show_frame_markers"), @"Show frame markers"); + + /// + /// "Show cursor path" + /// + public static LocalisableString ShowCursorPath => new TranslatableString(getKey(@"show_cursor_path"), @"Show cursor path"); + + /// + /// "Hide gameplay cursor" + /// + public static LocalisableString HideGameplayCursor => new TranslatableString(getKey(@"hide_gameplay_cursor"), @"Hide gameplay cursor"); + + /// + /// "Display length" + /// + public static LocalisableString DisplayLength => new TranslatableString(getKey(@"display_length"), @"Display length"); + + /// + /// "Playback" + /// + public static LocalisableString PlaybackTitle => new TranslatableString(getKey(@"playback_title"), @"Playback"); + + /// + /// "Visual Settings" + /// + public static LocalisableString VisualSettingsTitle => new TranslatableString(getKey(@"visual_settings_title"), @"Visual Settings"); + + /// + /// "Audio Settings" + /// + public static LocalisableString AudioSettingsTitle => new TranslatableString(getKey(@"audio_settings_title"), @"Audio Settings"); + + /// + /// "Input Settings" + /// + public static LocalisableString InputSettingsTitle => new TranslatableString(getKey(@"input_settings_title"), @"Input Settings"); + + /// + /// "Analysis Settings" + /// + public static LocalisableString AnalysisSettingsTitle => new TranslatableString(getKey(@"analysis_settings_title"), @"Analysis Settings"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ReplayFailIndicatorStrings.cs b/osu.Game/Localisation/ReplayFailIndicatorStrings.cs new file mode 100644 index 0000000000..f4507a1d96 --- /dev/null +++ b/osu.Game/Localisation/ReplayFailIndicatorStrings.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 ReplayFailIndicatorStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.ReplayFailIndicator"; + + /// + /// "Replay failed" + /// + public static LocalisableString ReplayFailed => new TranslatableString(getKey(@"replay_failed"), @"Replay failed"); + + /// + /// "Go to results" + /// + public static LocalisableString GoToResults => new TranslatableString(getKey(@"go_to_results"), @"Go to results"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/ResultsScreenStrings.cs b/osu.Game/Localisation/ResultsScreenStrings.cs index 54e7717af9..143d5b70bc 100644 --- a/osu.Game/Localisation/ResultsScreenStrings.cs +++ b/osu.Game/Localisation/ResultsScreenStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString NoPPForUnrankedMods => new TranslatableString(getKey(@"no_pp_for_unranked_mods"), @"Performance points are not granted for this score because of unranked mods."); + /// + /// "Performance points are not granted for failed scores." + /// + public static LocalisableString NoPPForFailedScores => new TranslatableString(getKey(@"no_pp_for_failed_scores"), @"Performance points are not granted for failed scores."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/RoomStatusPillStrings.cs b/osu.Game/Localisation/RoomStatusPillStrings.cs new file mode 100644 index 0000000000..5b4aa776ab --- /dev/null +++ b/osu.Game/Localisation/RoomStatusPillStrings.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.Localisation; + +namespace osu.Game.Localisation +{ + public static class RoomStatusPillStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.RoomStatusPill"; + + /// + /// "Ended" + /// + public static LocalisableString Ended => new TranslatableString(getKey(@"ended"), @"Ended"); + + /// + /// "Playing" + /// + public static LocalisableString Playing => new TranslatableString(getKey(@"playing"), @"Playing"); + + /// + /// "Open (Private)" + /// + public static LocalisableString OpenPrivate => new TranslatableString(getKey(@"open_private"), @"Open (Private)"); + + /// + /// "Open" + /// + public static LocalisableString Open => new TranslatableString(getKey(@"open"), @"Open"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs index 9434cd53de..891da585d8 100644 --- a/osu.Game/Localisation/RulesetSettingsStrings.cs +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -89,6 +89,31 @@ namespace osu.Game.Localisation /// public static LocalisableString TouchControlScheme => new TranslatableString(getKey(@"touch_control_scheme"), @"Touch control scheme"); + /// + /// "Mobile layout" + /// + public static LocalisableString MobileLayout => new TranslatableString(getKey(@"mobile_layout"), @"Mobile layout"); + + /// + /// "Portrait" + /// + public static LocalisableString Portrait => new TranslatableString(getKey(@"portrait"), @"Portrait"); + + /// + /// "Landscape" + /// + public static LocalisableString Landscape => new TranslatableString(getKey(@"landscape"), @"Landscape"); + + /// + /// "Landscape (expanded columns)" + /// + public static LocalisableString LandscapeExpandedColumns => new TranslatableString(getKey(@"landscape_expanded_columns"), @"Landscape (expanded columns)"); + + /// + /// "Touch overlay" + /// + public static LocalisableString TouchOverlay => new TranslatableString(getKey(@"touch_overlay"), @"Touch overlay"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs index 390a6f9ca4..4ddffe615f 100644 --- a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs +++ b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs @@ -14,11 +14,6 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), @"Attribute"); - /// - /// "The attribute to be displayed." - /// - public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), @"The attribute to be displayed."); - /// /// "Template" /// diff --git a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs index b21446e18a..2f34987e8e 100644 --- a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs +++ b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs @@ -14,31 +14,16 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString SpriteName => new TranslatableString(getKey(@"sprite_name"), @"Sprite name"); - /// - /// "The filename of the sprite" - /// - public static LocalisableString SpriteNameDescription => new TranslatableString(getKey(@"sprite_name_description"), @"The filename of the sprite"); - /// /// "Font" /// public static LocalisableString Font => new TranslatableString(getKey(@"font"), @"Font"); - /// - /// "The font to use." - /// - public static LocalisableString FontDescription => new TranslatableString(getKey(@"font_description"), @"The font to use."); - /// /// "Text" /// public static LocalisableString TextElementText => new TranslatableString(getKey(@"text_element_text"), @"Text"); - /// - /// "The text to be displayed." - /// - public static LocalisableString TextElementTextDescription => new TranslatableString(getKey(@"text_element_text_description"), @"The text to be displayed."); - /// /// "Corner radius" /// @@ -54,36 +39,37 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString ShowLabel => new TranslatableString(getKey(@"show_label"), @"Show label"); - /// - /// "Whether the component's label should be shown." - /// - public static LocalisableString ShowLabelDescription => new TranslatableString(getKey(@"show_label_description"), @"Whether the component's label should be shown."); - /// /// "Colour" /// public static LocalisableString Colour => new TranslatableString(getKey(@"colour"), @"Colour"); - /// - /// "The colour of the component." - /// - public static LocalisableString ColourDescription => new TranslatableString(getKey(@"colour_description"), @"The colour of the component."); - /// /// "Text colour" /// public static LocalisableString TextColour => new TranslatableString(getKey(@"text_colour"), @"Text colour"); /// - /// "The colour of the text." + /// "Text weight" /// - public static LocalisableString TextColourDescription => new TranslatableString(getKey(@"text_colour_description"), @"The colour of the text."); + public static LocalisableString TextWeight => new TranslatableString(getKey(@"text_weight"), @"Text weight"); /// /// "Use relative size" /// public static LocalisableString UseRelativeSize => new TranslatableString(getKey(@"use_relative_size"), @"Use relative size"); + /// + /// "Collapse during gameplay" + /// + public static LocalisableString CollapseDuringGameplay => new TranslatableString(getKey(@"collapse_during_gameplay"), @"Collapse during gameplay"); + + /// + /// "If enabled, the leaderboard will become more compact during active gameplay." + /// + public static LocalisableString CollapseDuringGameplayDescription => + new TranslatableString(getKey(@"if_enabled_the_leaderboard_will"), @"If enabled, the leaderboard will become more compact during active gameplay."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinComponents/SkinnableModDisplayStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableModDisplayStrings.cs new file mode 100644 index 0000000000..22f9fe6d02 --- /dev/null +++ b/osu.Game/Localisation/SkinComponents/SkinnableModDisplayStrings.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.SkinComponents +{ + public static class SkinnableModDisplayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SkinnableModDisplay"; + + /// + /// "Show extended information" + /// + public static LocalisableString ShowExtendedInformation => new TranslatableString(getKey(@"show_extended_information"), @"Show extended information"); + + /// + /// "Whether to show extended information for each mod." + /// + public static LocalisableString ShowExtendedInformationDescription => + new TranslatableString(getKey(@"whether_to_show_extended_information"), @"Whether to show extended information for each mod."); + + /// + /// "Display direction" + /// + public static LocalisableString DisplayDirection => new TranslatableString(getKey(@"display_direction"), "Display direction"); + + /// + /// "Expansion mode" + /// + public static LocalisableString ExpansionMode => new TranslatableString(getKey(@"expansion_mode"), @"Expansion mode"); + + /// + /// "How the mod display expands when interacted with." + /// + public static LocalisableString ExpansionModeDescription => new TranslatableString(getKey(@"how_the_mod_display_expands"), @"How the mod display expands when interacted with."); + + /// + /// "Expand on hover" + /// + public static LocalisableString ExpandOnHover => new TranslatableString(getKey(@"expand_on_hover"), @"Expand on hover"); + + /// + /// "Always contracted" + /// + public static LocalisableString AlwaysContracted => new TranslatableString(getKey(@"always_contracted"), @"Always contracted"); + + /// + /// "Always expanded" + /// + public static LocalisableString AlwaysExpanded => new TranslatableString(getKey(@"always_expanded"), @"Always expanded"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index 4b6b0ce1d6..16dca7fd87 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -54,16 +54,6 @@ namespace osu.Game.Localisation /// 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"); - - /// - /// "Delete selected skin" - /// - public static LocalisableString DeleteSkinButton => new TranslatableString(getKey(@"delete_skin_button"), @"Delete selected skin"); - private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SongSelectStrings.cs b/osu.Game/Localisation/SongSelectStrings.cs index e715ba8880..c81cf97f09 100644 --- a/osu.Game/Localisation/SongSelectStrings.cs +++ b/osu.Game/Localisation/SongSelectStrings.cs @@ -9,6 +9,26 @@ namespace osu.Game.Localisation { private const string prefix = @"osu.Game.Resources.Localisation.SongSelect"; + /// + /// "Mods" + /// + public static LocalisableString Mods => new TranslatableString(getKey(@"mods"), @"Mods"); + + /// + /// "Random" + /// + public static LocalisableString Random => new TranslatableString(getKey(@"random"), @"Random"); + + /// + /// "Rewind" + /// + public static LocalisableString Rewind => new TranslatableString(getKey(@"rewind"), @"Rewind"); + + /// + /// "Options" + /// + public static LocalisableString Options => new TranslatableString(getKey(@"options"), @"Options"); + /// /// "Local" /// @@ -20,39 +40,224 @@ namespace osu.Game.Localisation public static LocalisableString LocallyModifiedTooltip => new TranslatableString(getKey(@"locally_modified_tooltip"), @"Has been locally modified"); /// - /// "Manage collections" + /// "Unknown" /// - public static LocalisableString ManageCollections => new TranslatableString(getKey(@"manage_collections"), @"Manage collections"); + public static LocalisableString StatusUnknown => new TranslatableString(getKey(@"status_unknown"), @"Unknown"); + + /// + /// "Total Plays" + /// + public static LocalisableString TotalPlays => new TranslatableString(getKey(@"total_plays"), @"Total Plays"); + + /// + /// "Personal Plays" + /// + public static LocalisableString PersonalPlays => new TranslatableString(getKey(@"personal_plays"), @"Personal Plays"); + + /// + /// "Circle Size" + /// + public static LocalisableString CircleSize => new TranslatableString(getKey(@"circle_size"), @"Circle Size"); + + /// + /// "Key Count" + /// + public static LocalisableString KeyCount => new TranslatableString(getKey(@"key_count"), @"Key Count"); + + /// + /// "Approach Rate" + /// + public static LocalisableString ApproachRate => new TranslatableString(getKey(@"approach_rate"), @"Approach Rate"); + + /// + /// "Accuracy" + /// + public static LocalisableString Accuracy => new TranslatableString(getKey(@"accuracy"), @"Accuracy"); + + /// + /// "HP Drain" + /// + public static LocalisableString HPDrain => new TranslatableString(getKey(@"hp_drain"), @"HP Drain"); + + /// + /// "Scroll Speed" + /// + public static LocalisableString ScrollSpeed => new TranslatableString(getKey(@"scroll_speed"), @"Scroll Speed"); + + /// + /// "Submitted" + /// + public static LocalisableString Submitted => new TranslatableString(getKey(@"submitted"), @"Submitted"); + + /// + /// "Ranked" + /// + public static LocalisableString Ranked => new TranslatableString(getKey(@"ranked"), @"Ranked"); + + /// + /// "Details" + /// + public static LocalisableString Details => new TranslatableString(getKey(@"details"), @"Details"); + + /// + /// "Ranking" + /// + public static LocalisableString Ranking => new TranslatableString(getKey(@"ranking"), @"Ranking"); + + /// + /// "Use these mods" + /// + public static LocalisableString UseTheseMods => new TranslatableString(getKey(@"use_these_mods"), @"Use these mods"); /// /// "For all difficulties" /// public static LocalisableString ForAllDifficulties => new TranslatableString(getKey(@"for_all_difficulties"), @"For all difficulties"); - /// - /// "Delete beatmap" - /// - public static LocalisableString DeleteBeatmap => new TranslatableString(getKey(@"delete_beatmap"), @"Delete beatmap"); - /// /// "For selected difficulty" /// public static LocalisableString ForSelectedDifficulty => new TranslatableString(getKey(@"for_selected_difficulty"), @"For selected difficulty"); + /// + /// "Update beatmap with online changes" + /// + public static LocalisableString UpdateBeatmapTooltip => new TranslatableString(getKey(@"update_beatmap_tooltip"), @"Update beatmap with online changes"); + /// /// "Mark as played" /// public static LocalisableString MarkAsPlayed => new TranslatableString(getKey(@"mark_as_played"), @"Mark as played"); + /// + /// "Remove from played" + /// + public static LocalisableString RemoveFromPlayed => new TranslatableString(getKey(@"remove_from_played"), @"Remove from played"); + /// /// "Clear all local scores" /// public static LocalisableString ClearAllLocalScores => new TranslatableString(getKey(@"clear_all_local_scores"), @"Clear all local scores"); /// - /// "Edit beatmap" + /// "Delete beatmap" /// - public static LocalisableString EditBeatmap => new TranslatableString(getKey(@"edit_beatmap"), @"Edit beatmap"); + public static LocalisableString DeleteBeatmap => new TranslatableString(getKey(@"delete_beatmap"), @"Delete beatmap"); + + /// + /// "Restore all hidden" + /// + public static LocalisableString RestoreAllHidden => new TranslatableString(getKey(@"restore_all_hidden"), @"Restore all hidden"); + + /// + /// "{0} stars" + /// + public static LocalisableString Stars(LocalisableString value) => new TranslatableString(getKey(@"stars"), @"{0} stars", value); + + /// + /// "Sort" + /// + public static LocalisableString Sort => new TranslatableString(getKey(@"sort"), @"Sort"); + + /// + /// "Group" + /// + public static LocalisableString Group => new TranslatableString(getKey(@"group"), @"Group"); + + /// + /// "None" + /// + public static LocalisableString None => new TranslatableString(getKey(@"none"), @"None"); + + /// + /// "Title" + /// + public static LocalisableString Title => new TranslatableString(getKey(@"title"), @"Title"); + + /// + /// "Artist" + /// + public static LocalisableString Artist => new TranslatableString(getKey(@"artist"), @"Artist"); + + /// + /// "Author" + /// + public static LocalisableString Author => new TranslatableString(getKey(@"author"), @"Author"); + + /// + /// "BPM" + /// + public static LocalisableString BPM => new TranslatableString(getKey(@"bpm"), @"BPM"); + + /// + /// "Date Submitted" + /// + public static LocalisableString DateSubmitted => new TranslatableString(getKey(@"date_submitted"), @"Date Submitted"); + + /// + /// "Date Ranked" + /// + public static LocalisableString DateRanked => new TranslatableString(getKey(@"date_ranked"), @"Date Ranked"); + + /// + /// "Date Added" + /// + public static LocalisableString DateAdded => new TranslatableString(getKey(@"date_added"), @"Date Added"); + + /// + /// "Last Played" + /// + public static LocalisableString LastPlayed => new TranslatableString(getKey(@"last_played"), @"Last Played"); + + /// + /// "Difficulty" + /// + public static LocalisableString Difficulty => new TranslatableString(getKey(@"difficulty"), @"Difficulty"); + + /// + /// "Length" + /// + public static LocalisableString Length => new TranslatableString(getKey(@"length"), @"Length"); + + /// + /// "Favourites" + /// + public static LocalisableString Favourites => new TranslatableString(getKey(@"favourites"), @"Favourites"); + + /// + /// "My Maps" + /// + public static LocalisableString MyMaps => new TranslatableString(getKey(@"my_maps"), @"My Maps"); + + /// + /// "Collections" + /// + public static LocalisableString Collections => new TranslatableString(getKey(@"collections"), @"Collections"); + + /// + /// "Rank Achieved" + /// + public static LocalisableString RankAchieved => new TranslatableString(getKey(@"rank_achieved"), @"Rank Achieved"); + + /// + /// "Ranked Status" + /// + public static LocalisableString RankedStatus => new TranslatableString(getKey(@"ranked_status"), @"Ranked Status"); + + /// + /// "Source" + /// + public static LocalisableString Source => new TranslatableString(getKey(@"source"), @"Source"); + + /// + /// "No matching beatmaps" + /// + public static LocalisableString NoMatchingBeatmaps => new TranslatableString(getKey(@"no_matching_beatmaps"), @"No matching beatmaps"); + + /// + /// "No beatmaps match your filter criteria!" + /// + public static LocalisableString NoMatchingBeatmapsDescription => new TranslatableString(getKey(@"no_matching_beatmaps_description"), @"No beatmaps match your filter criteria!"); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/TabletSettingsStrings.cs b/osu.Game/Localisation/TabletSettingsStrings.cs index 6c2e3c1f9c..ff0ced457f 100644 --- a/osu.Game/Localisation/TabletSettingsStrings.cs +++ b/osu.Game/Localisation/TabletSettingsStrings.cs @@ -59,6 +59,11 @@ namespace osu.Game.Localisation /// public static LocalisableString LockAspectRatio => new TranslatableString(getKey(@"lock_aspect_ratio"), @"Lock aspect ratio"); + /// + /// "Tip pressure for click" + /// + public static LocalisableString TipPressureForClick => new TranslatableString(getKey(@"tip_pressure_for_click"), "Tip pressure for click"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ToastStrings.cs b/osu.Game/Localisation/ToastStrings.cs index 49e8d00371..b520511d8f 100644 --- a/osu.Game/Localisation/ToastStrings.cs +++ b/osu.Game/Localisation/ToastStrings.cs @@ -45,9 +45,9 @@ namespace osu.Game.Localisation public static LocalisableString SkinSaved => new TranslatableString(getKey(@"skin_saved"), @"Skin saved"); /// - /// "Link copied to clipboard" + /// "Copied to clipboard" /// - public static LocalisableString UrlCopied => new TranslatableString(getKey(@"url_copied"), @"Link copied to clipboard"); + public static LocalisableString CopiedToClipboard => new TranslatableString(getKey(@"copied_to_clipboard"), @"Copied to clipboard"); /// /// "Speed changed to {0:N2}x" diff --git a/osu.Game/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs index dceedca05c..7fbccf1919 100644 --- a/osu.Game/Localisation/UserInterfaceStrings.cs +++ b/osu.Game/Localisation/UserInterfaceStrings.cs @@ -84,6 +84,11 @@ namespace osu.Game.Localisation /// public static LocalisableString RightMouseScroll => new TranslatableString(getKey(@"right_mouse_scroll"), @"Right mouse drag to absolute scroll"); + /// + /// "Show converts" + /// + public static LocalisableString ShowConverts => new TranslatableString(getKey(@"show_converts"), @"Show converts"); + /// /// "Show converted beatmaps" /// @@ -159,6 +164,11 @@ namespace osu.Game.Localisation /// public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True Random"); + /// + /// "Selected Mods" + /// + public static LocalisableString SelectedMods => new TranslatableString(getKey(@"selected_mods"), @"Selected Mods"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index ec48fa2436..6694003b31 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -13,9 +13,11 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using osu.Framework.Bindables; +using osu.Framework.Development; +using osu.Framework.Extensions; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Localisation; @@ -23,11 +25,10 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Notifications.WebSocket; -using osu.Game.Users; namespace osu.Game.Online.API { - public partial class APIAccess : Component, IAPIProvider + public partial class APIAccess : CompositeComponent, IAPIProvider { private readonly OsuGameBase game; private readonly OsuConfigManager config; @@ -38,9 +39,7 @@ namespace osu.Game.Online.API private readonly Queue queue = new Queue(); - public string APIEndpointUrl { get; } - - public string WebsiteRootUrl { get; } + public EndpointConfiguration Endpoints { get; } /// /// The API response version. @@ -52,34 +51,27 @@ namespace osu.Game.Online.API public string ProvidedUsername { get; private set; } + public SessionVerificationMethod? SessionVerificationMethod { get; private set; } + public string SecondFactorCode { get; private set; } private string password; - public IBindable LocalUser => localUser; - public IBindableList Friends => friends; - public IBindable Activity => activity; + public IBindable LocalUser => localUserState.User; + + public ILocalUserState LocalUserState => localUserState; + private readonly LocalUserState localUserState; public INotificationsClient NotificationsClient { get; } public Language Language => game.CurrentLanguage.Value; - private Bindable localUser { get; } = new Bindable(createGuestUser()); - - private BindableList friends { get; } = new BindableList(); - - private Bindable activity { get; } = new Bindable(); - - private Bindable configStatus { get; } = new Bindable(); - private Bindable localUserStatus { get; } = new Bindable(); - protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); - private readonly Logger log; - public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) + public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpoints, string versionHash) { this.game = game; this.config = config; @@ -93,14 +85,13 @@ namespace osu.Game.Online.API APIVersion = now.Year * 10000 + now.Month * 100 + now.Day; } - APIEndpointUrl = endpointConfiguration.APIEndpointUrl; - WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; + Endpoints = endpoints; NotificationsClient = setUpNotificationsClient(); - authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl); + authentication = new OAuth(endpoints.APIClientID, endpoints.APIClientSecret, Endpoints.APIUrl); log = Logger.GetLogger(LoggingTarget.Network); - log.Add($@"API endpoint root: {APIEndpointUrl}"); + log.Add($@"API endpoint root: {Endpoints.APIUrl}"); log.Add($@"API request version: {APIVersion}"); ProvidedUsername = config.Get(OsuSetting.Username); @@ -108,18 +99,16 @@ namespace osu.Game.Online.API authentication.TokenString = config.Get(OsuSetting.Token); authentication.Token.ValueChanged += onTokenChanged; - config.BindWith(OsuSetting.UserOnlineStatus, configStatus); + AddInternal(localUserState = new LocalUserState(this, config)); - localUser.BindValueChanged(u => + if (HasLogin) { - u.OldValue?.Activity.UnbindFrom(activity); - u.NewValue.Activity.BindTo(activity); + // Early call to ensure the local user / "logged in" state is correct immediately. + localUserState.SetPlaceholderLocalUser(ProvidedUsername); - u.OldValue?.Status.UnbindFrom(localUserStatus); - u.NewValue.Status.BindTo(localUserStatus); - }, true); - - localUserStatus.BindTo(configStatus); + // This is required so that Queue() requests during startup sequence don't fail due to "not logged in". + state.Value = APIState.Connecting; + } var thread = new Thread(run) { @@ -193,13 +182,16 @@ namespace osu.Game.Online.API Debug.Assert(HasLogin); - // Ensure that we are in an online state. If not, attempt a connect. + // Ensure that we are in an online state. If not, attempt to connect. if (state.Value != APIState.Online) { attemptConnect(); if (state.Value != APIState.Online) + { + Thread.Sleep(50); continue; + } } // hard bail if we can't get a valid access token. @@ -247,22 +239,13 @@ namespace osu.Game.Online.API /// Whether the connection attempt was successful. private void attemptConnect() { - if (localUser.IsDefault) - { - // Show a placeholder user if saved credentials are available. - // This is useful for storing local scores and showing a placeholder username after starting the game, - // until a valid connection has been established. - setLocalUser(new APIUser - { - Username = ProvidedUsername, - Status = { Value = configStatus.Value ?? UserStatus.Online } - }); - } + if (LocalUser.IsDefault) + Scheduler.Add(localUserState.SetPlaceholderLocalUser, ProvidedUsername, false); // 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) + if (!authentication.HasValidAccessToken && HasLogin) { state.Value = APIState.Connecting; LastLoginError = null; @@ -271,6 +254,10 @@ namespace osu.Game.Online.API { authentication.AuthenticateWithLogin(ProvidedUsername, password); } + catch (WebRequestFlushedException) + { + return; + } catch (Exception e) { //todo: this fails even on network-related issues. we should probably handle those differently. @@ -298,7 +285,17 @@ namespace osu.Game.Online.API verificationRequest.Failure += ex => { state.Value = APIState.RequiresSecondFactorAuth; - LastLoginError = ex; + + if (verificationRequest.RequiredVerificationMethod != null) + { + SessionVerificationMethod = verificationRequest.RequiredVerificationMethod; + LastLoginError = new APIException($"Must use {SessionVerificationMethod.GetDescription().ToLowerInvariant()} to complete verification.", ex); + } + else + { + LastLoginError = ex; + } + SecondFactorCode = null; }; @@ -331,7 +328,7 @@ namespace osu.Game.Online.API log.Add(@"Login no longer valid"); Logout(); } - else + else if (ex is not WebRequestFlushedException) { state.Value = APIState.Failing; } @@ -339,11 +336,11 @@ namespace osu.Game.Online.API userReq.Success += me => { - me.Status.Value = configStatus.Value ?? UserStatus.Online; + Debug.Assert(ThreadSafety.IsUpdateThread); - setLocalUser(me); - - state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth; + localUserState.SetLocalUser(me); + SessionVerificationMethod = me.SessionVerificationMethod; + state.Value = SessionVerificationMethod == null ? APIState.Online : APIState.RequiresSecondFactorAuth; failureCount = 0; }; @@ -357,8 +354,6 @@ namespace osu.Game.Online.API } } - UpdateLocalFriends(); - // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests // before actually going online. @@ -398,8 +393,8 @@ namespace osu.Game.Online.API SecondFactorCode = code; } - public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => - new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); + public IHubClientConnector GetHubConnector(string clientName, string endpoint) => + new HubClientConnector(clientName, endpoint, this, versionHash); public IChatClient GetChatClient() => new WebSocketChatClient(this); @@ -409,7 +404,7 @@ namespace osu.Game.Online.API var req = new RegistrationRequest { - Url = $@"{APIEndpointUrl}/users", + Url = $@"{Endpoints.APIUrl}/users", Method = HttpMethod.Post, Username = username, Email = email, @@ -497,6 +492,11 @@ namespace osu.Game.Online.API handleWebException(we); return false; } + catch (WebRequestFlushedException wrf) + { + log.Add(wrf.Message); + return false; + } catch (Exception ex) { Logger.Error(ex, "Error occurred while handling an API request."); @@ -578,7 +578,7 @@ namespace osu.Game.Online.API if (failOldRequests) { foreach (var req in oldQueueRequests) - req.Fail(new WebException($@"Request failed from flush operation (state {state.Value})")); + req.Fail(new WebRequestFlushedException(state.Value)); } } } @@ -588,39 +588,13 @@ namespace osu.Game.Online.API password = null; SecondFactorCode = null; authentication.Clear(); - configStatus.Value = UserStatus.Online; - // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present - Schedule(() => - { - setLocalUser(createGuestUser()); - friends.Clear(); - }); + localUserState.ClearLocalUser(); state.Value = APIState.Offline; flushQueue(); } - public void UpdateLocalFriends() - { - if (!IsLoggedIn) - return; - - var friendsReq = new GetFriendsRequest(); - friendsReq.Failure += _ => state.Value = APIState.Failing; - friendsReq.Success += res => - { - friends.Clear(); - friends.AddRange(res); - }; - - Queue(friendsReq); - } - - private static APIUser createGuestUser() => new GuestUser(); - - private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false); - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -628,6 +602,14 @@ namespace osu.Game.Online.API flushQueue(); cancellationToken.Cancel(); } + + private class WebRequestFlushedException : Exception + { + public WebRequestFlushedException(APIState state) + : base($@"Request failed from flush operation (state {state})") + { + } + } } internal class GuestUser : APIUser diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 5cbe9040ba..9d9873cc6f 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -71,7 +71,7 @@ namespace osu.Game.Online.API protected virtual WebRequest CreateWebRequest() => new OsuWebRequest(Uri); - protected virtual string Uri => $@"{API!.APIEndpointUrl}/api/v2/{Target}"; + protected virtual string Uri => $@"{API!.Endpoints.APIUrl}/api/v2/{Target}"; protected IAPIProvider? API; diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 5d63c04925..c01d0ca480 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -5,6 +5,7 @@ using System; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Game.Localisation; using osu.Game.Online.API.Requests; @@ -12,7 +13,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Notifications.WebSocket; using osu.Game.Tests; -using osu.Game.Users; namespace osu.Game.Online.API { @@ -20,15 +20,11 @@ namespace osu.Game.Online.API { public const int DUMMY_USER_ID = 1001; - public Bindable LocalUser { get; } = new Bindable(new APIUser - { - Username = @"Local user", - Id = DUMMY_USER_ID, - }); + public DummyLocalUserState LocalUserState { get; } = new DummyLocalUserState(); + public Bindable LocalUser => LocalUserState.User; - public BindableList Friends { get; } = new BindableList(); - - public Bindable Activity { get; } = new Bindable(); + ILocalUserState IAPIProvider.LocalUserState => LocalUserState; + IBindable IAPIProvider.LocalUser => LocalUser; public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient(); INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient; @@ -44,9 +40,11 @@ namespace osu.Game.Online.API public string ProvidedUsername => LocalUser.Value.Username; - public string APIEndpointUrl => "http://localhost"; - - public string WebsiteRootUrl => "http://localhost"; + public EndpointConfiguration Endpoints { get; } = new EndpointConfiguration + { + APIUrl = "http://localhost", + WebsiteUrl = "http://localhost", + }; public int APIVersion => int.Parse(DateTime.Now.ToString("yyyyMMdd")); @@ -62,22 +60,14 @@ namespace osu.Game.Online.API private bool shouldFailNextLogin; private bool stayConnectingNextLogin; - private bool requiredSecondFactorAuth = true; + + public SessionVerificationMethod? SessionVerificationMethod { get; set; } = Requests.Responses.SessionVerificationMethod.EmailMessage; /// /// The current connectivity state of the API. /// public IBindable State => state; - public DummyAPIAccess() - { - LocalUser.BindValueChanged(u => - { - u.OldValue?.Activity.UnbindFrom(Activity); - u.NewValue.Activity.BindTo(Activity); - }, true); - } - public virtual void Queue(APIRequest request) { request.AttachAPI(this); @@ -139,14 +129,14 @@ namespace osu.Game.Online.API Id = DUMMY_USER_ID, }; - if (requiredSecondFactorAuth) + if (SessionVerificationMethod != null) { state.Value = APIState.RequiresSecondFactorAuth; } else { onSuccessfulLogin(); - requiredSecondFactorAuth = true; + SessionVerificationMethod = null; } } @@ -156,7 +146,16 @@ namespace osu.Game.Online.API request.Failure += e => { state.Value = APIState.RequiresSecondFactorAuth; - LastLoginError = e; + + if (request.RequiredVerificationMethod != null) + { + SessionVerificationMethod = request.RequiredVerificationMethod; + LastLoginError = new APIException($"Must use {SessionVerificationMethod.GetDescription().ToLowerInvariant()} to complete verification.", e); + } + else + { + LastLoginError = e; + } }; state.Value = APIState.Connecting; @@ -190,7 +189,11 @@ namespace osu.Game.Online.API { } - public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; + public void UpdateLocalBlocks() + { + } + + public IHubClientConnector? GetHubConnector(string clientName, string endpoint) => null; public IChatClient GetChatClient() => new TestChatClientConnector(this); @@ -202,14 +205,10 @@ namespace osu.Game.Online.API public void SetState(APIState newState) => state.Value = newState; - IBindable IAPIProvider.LocalUser => LocalUser; - IBindableList IAPIProvider.Friends => Friends; - IBindable IAPIProvider.Activity => Activity; - /// /// Skip 2FA requirement for next login. /// - public void SkipSecondFactor() => requiredSecondFactorAuth = false; + public void SkipSecondFactor() => SessionVerificationMethod = null; /// /// During the next simulated login, the process will fail immediately. @@ -228,5 +227,35 @@ namespace osu.Game.Online.API // Ensure (as much as we can) that any pending tasks are run. Scheduler.Update(); } + + public class DummyLocalUserState : ILocalUserState + { + public Bindable User { get; } = new Bindable(new APIUser + { + Username = @"Local user", + Id = DUMMY_USER_ID, + }); + + public BindableList Friends { get; } = new BindableList(); + public BindableList Blocks { get; } = new BindableList(); + public BindableList FavouriteBeatmapSets { get; } = new BindableList(); + + IBindable ILocalUserState.User => User; + IBindableList ILocalUserState.Friends => Friends; + IBindableList ILocalUserState.Blocks => Blocks; + IBindableList ILocalUserState.FavouriteBeatmapSets => FavouriteBeatmapSets; + + public void UpdateFriends() + { + } + + public void UpdateBlocks() + { + } + + public void UpdateFavouriteBeatmapSets() + { + } + } } } diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 1c4b2da742..de1635fa80 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -8,7 +8,6 @@ using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Notifications.WebSocket; -using osu.Game.Users; namespace osu.Game.Online.API { @@ -20,14 +19,11 @@ namespace osu.Game.Online.API IBindable LocalUser { get; } /// - /// The user's friends. + /// The local user's current state. + /// Contains auxiliary information such as the user's friends, blocks, and favourites, + /// as well as methods to manage those in a way that keeps this state consistent throughout the game. /// - IBindableList Friends { get; } - - /// - /// The current user's activity. - /// - IBindable Activity { get; } + ILocalUserState LocalUserState { get; } /// /// The language supplied by this provider to API requests. @@ -57,14 +53,9 @@ namespace osu.Game.Online.API string ProvidedUsername { get; } /// - /// The URL endpoint for this API. Does not include a trailing slash. + /// Holds configuration for online endpoints. /// - string APIEndpointUrl { get; } - - /// - /// The root URL of the website, excluding the trailing slash. - /// - string WebsiteRootUrl { get; } + EndpointConfiguration Endpoints { get; } /// /// The version of the API. @@ -113,10 +104,15 @@ namespace osu.Game.Online.API /// The user's password. void Login(string username, string password); + /// + /// The requested by the server to complete verification. + /// + SessionVerificationMethod? SessionVerificationMethod { get; } + /// /// Provide a second-factor authentication code for authentication. /// - /// The 2FA code. + /// The 2FA code. void AuthenticateSecondFactor(string code); /// @@ -124,11 +120,6 @@ namespace osu.Game.Online.API /// void Logout(); - /// - /// Update the friends status of the current user. - /// - void UpdateLocalFriends(); - /// /// Schedule a callback to run on the update thread. /// @@ -139,8 +130,7 @@ namespace osu.Game.Online.API /// /// The name of the client this connector connects for, used for logging. /// The endpoint to the hub. - /// Whether to use MessagePack for serialisation if available on this platform. - IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true); + IHubClientConnector? GetHubConnector(string clientName, string endpoint); /// /// Accesses the used to receive asynchronous notifications from web. diff --git a/osu.Game/Online/API/ILocalUserState.cs b/osu.Game/Online/API/ILocalUserState.cs new file mode 100644 index 0000000000..4c5cbcf197 --- /dev/null +++ b/osu.Game/Online/API/ILocalUserState.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.API.Requests.Responses; + +namespace osu.Game.Online.API +{ + public interface ILocalUserState + { + IBindable User { get; } + IBindableList Friends { get; } + IBindableList Blocks { get; } + IBindableList FavouriteBeatmapSets { get; } + + void UpdateFriends(); + void UpdateBlocks(); + void UpdateFavouriteBeatmapSets(); + } +} diff --git a/osu.Game/Online/API/LocalUserState.cs b/osu.Game/Online/API/LocalUserState.cs new file mode 100644 index 0000000000..94b298fdb4 --- /dev/null +++ b/osu.Game/Online/API/LocalUserState.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; + +namespace osu.Game.Online.API +{ + public partial class LocalUserState : Component, ILocalUserState + { + public IBindable User => localUser; + public IBindableList Friends => friends; + public IBindableList Blocks => blocks; + public IBindableList FavouriteBeatmapSets => favouriteBeatmapSets; + + private readonly IAPIProvider api; + + private readonly Bindable localUser = new Bindable(createGuestUser()); + private readonly BindableList friends = new BindableList(); + private readonly BindableList blocks = new BindableList(); + private readonly BindableList favouriteBeatmapSets = new BindableList(); + + private readonly Bindable configStatus = new Bindable(); + private readonly Bindable configSupporter = new Bindable(); + + public LocalUserState(IAPIProvider api, OsuConfigManager config) + { + this.api = api; + + config.BindWith(OsuSetting.UserOnlineStatus, configStatus); + config.BindWith(OsuSetting.WasSupporter, configSupporter); + } + + #region Logging in / out + + private static APIUser createGuestUser() => new GuestUser(); + + /// + /// Show a placeholder user if saved credentials are available. + /// This is useful for storing local scores and showing a placeholder username after starting the game, + /// until a valid connection has been established. + /// + public void SetPlaceholderLocalUser(string username) + { + if (!localUser.IsDefault) + return; + + localUser.Value = new APIUser + { + Username = username, + IsSupporter = configSupporter.Value, + }; + } + + public void SetLocalUser(APIMe me) + { + localUser.Value = me; + configSupporter.Value = me.IsSupporter; + + // `last_visit` is assumed to be `null` if and only if the web-side "hide online presence toggle" is enabled + if (me.LastVisit == null) + configStatus.Value = UserStatus.Offline; + + UpdateFriends(); + UpdateBlocks(); + UpdateFavouriteBeatmapSets(); + } + + public void ClearLocalUser() + { + // Reset the status to be broadcast on the next login, in case multiple players share the same system. + configStatus.Value = UserStatus.Online; + + // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present + Schedule(() => + { + localUser.Value = createGuestUser(); + configSupporter.Value = false; + friends.Clear(); + blocks.Clear(); + favouriteBeatmapSets.Clear(); + }); + } + + #endregion + + public void UpdateFriends() + { + if (!api.IsLoggedIn) + return; + + var friendsReq = new GetFriendsRequest(); + friendsReq.Success += res => + { + var existingFriends = friends.Select(f => f.TargetID).ToHashSet(); + var updatedFriends = res.Select(f => f.TargetID).ToHashSet(); + + // Add new friends into local list. + friends.AddRange(res.Where(r => !existingFriends.Contains(r.TargetID))); + + // Remove non-friends from local list. + friends.RemoveAll(f => !updatedFriends.Contains(f.TargetID)); + }; + + api.Queue(friendsReq); + } + + public void UpdateBlocks() + { + if (!api.IsLoggedIn) + return; + + var blocksReq = new GetBlocksRequest(); + blocksReq.Success += res => + { + var existingBlocks = blocks.Select(f => f.TargetID).ToHashSet(); + var updatedBlocks = res.Select(f => f.TargetID).ToHashSet(); + + // Add new blocked users to local list. + blocks.AddRange(res.Where(r => !existingBlocks.Contains(r.TargetID))); + + // Remove non-blocked users from local list. + blocks.RemoveAll(b => !updatedBlocks.Contains(b.TargetID)); + + // Remove friends who got blocked since last check. + friends.RemoveAll(f => updatedBlocks.Contains(f.TargetID)); + }; + + api.Queue(blocksReq); + } + + public void UpdateFavouriteBeatmapSets() + { + if (!api.IsLoggedIn) + return; + + var favouritesReq = new GetMyFavouriteBeatmapSetsRequest(); + favouritesReq.Success += res => + { + var existingBeatmapSets = favouriteBeatmapSets.ToHashSet(); + var updatedBeatmapSets = res.BeatmapSetIds.ToHashSet(); + + favouriteBeatmapSets.AddRange(updatedBeatmapSets.Except(existingBeatmapSets)); + favouriteBeatmapSets.RemoveAll(b => !updatedBeatmapSets.Contains(b)); + }; + + api.Queue(favouritesReq); + } + } +} diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs index 3fad032531..8da83d2aad 100644 --- a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -10,10 +10,12 @@ using osu.Game.Configuration; namespace osu.Game.Online.API { - public class ModSettingsDictionaryFormatter : IMessagePackFormatter> + public class ModSettingsDictionaryFormatter : IMessagePackFormatter?> { - public void Serialize(ref MessagePackWriter writer, Dictionary value, MessagePackSerializerOptions options) + public void Serialize(ref MessagePackWriter writer, Dictionary? value, MessagePackSerializerOptions options) { + if (value == null) return; + var primitiveFormatter = PrimitiveObjectFormatter.Instance; writer.WriteArrayHeader(value.Count); diff --git a/osu.Game/Online/API/Requests/APIUploadRequest.cs b/osu.Game/Online/API/Requests/APIUploadRequest.cs new file mode 100644 index 0000000000..3503b4cebb --- /dev/null +++ b/osu.Game/Online/API/Requests/APIUploadRequest.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public abstract class APIUploadRequest : APIRequest + { + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.UploadProgress += onUploadProgress; + return request; + } + + private void onUploadProgress(long current, long total) + { + Debug.Assert(API != null); + API.Schedule(() => Progressed?.Invoke(current, total)); + } + + public event APIProgressHandler? Progressed; + } +} diff --git a/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs b/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs new file mode 100644 index 0000000000..911c4fa5f1 --- /dev/null +++ b/osu.Game/Online/API/Requests/AddBeatmapTagRequest.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.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class AddBeatmapTagRequest : APIRequest + { + public int BeatmapID { get; } + public long TagID { get; } + + public AddBeatmapTagRequest(int beatmapID, long tagID) + { + BeatmapID = beatmapID; + TagID = tagID; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Put; + return req; + } + + protected override string Target => $@"beatmaps/{BeatmapID}/tags/{TagID}"; + } +} diff --git a/osu.Game/Online/API/Requests/BlockUserRequest.cs b/osu.Game/Online/API/Requests/BlockUserRequest.cs new file mode 100644 index 0000000000..bfcce075eb --- /dev/null +++ b/osu.Game/Online/API/Requests/BlockUserRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class BlockUserRequest : APIRequest + { + public readonly int TargetId; + + public BlockUserRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + req.AddParameter("target", TargetId.ToString(), RequestParameterType.Query); + + return req; + } + + protected override string Target => @"blocks"; + } +} diff --git a/osu.Game/Online/API/Requests/GetBlocksRequest.cs b/osu.Game/Online/API/Requests/GetBlocksRequest.cs new file mode 100644 index 0000000000..c16c256870 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetBlocksRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetBlocksRequest : APIRequest> + { + protected override string Target => @"blocks"; + } +} diff --git a/osu.Game/Online/API/Requests/GetMyFavouriteBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/GetMyFavouriteBeatmapSetsRequest.cs new file mode 100644 index 0000000000..87a901c98e --- /dev/null +++ b/osu.Game/Online/API/Requests/GetMyFavouriteBeatmapSetsRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetMyFavouriteBeatmapSetsRequest : APIRequest + { + protected override string Target => @"me/beatmapset-favourites"; + } +} diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index f2a2daccb5..87fb54a5a9 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -7,15 +7,20 @@ using osu.Game.Rulesets; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Mods; -using System.Text; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using osu.Framework.IO.Network; +using osu.Game.Extensions; namespace osu.Game.Online.API.Requests { public class GetScoresRequest : APIRequest, IEquatable { - public const int MAX_SCORES_PER_REQUEST = 50; + public const int DEFAULT_SCORES_PER_REQUEST = 50; + public const int MAX_SCORES_PER_REQUEST = 100; + + public int ScoresRequested { get; } private readonly IBeatmapInfo beatmapInfo; private readonly BeatmapLeaderboardScope scope; @@ -34,21 +39,24 @@ namespace osu.Game.Online.API.Requests this.scope = scope; this.ruleset = ruleset ?? throw new ArgumentNullException(nameof(ruleset)); this.mods = mods ?? Array.Empty(); + + ScoresRequested = this.scope.RequiresSupporter(this.mods.Any()) ? MAX_SCORES_PER_REQUEST : DEFAULT_SCORES_PER_REQUEST; } - protected override string Target => $@"beatmaps/{beatmapInfo.OnlineID}/solo-scores{createQueryParameters()}"; + protected override string Target => $@"beatmaps/{beatmapInfo.OnlineID}/scores"; - private string createQueryParameters() + protected override WebRequest CreateWebRequest() { - StringBuilder query = new StringBuilder(@"?"); + var req = base.CreateWebRequest(); - query.Append($@"type={scope.ToString().ToLowerInvariant()}"); - query.Append($@"&mode={ruleset.ShortName}"); + req.AddParameter(@"type", scope.ToString().ToLowerInvariant()); + req.AddParameter(@"mode", ruleset.ShortName); foreach (var mod in mods) - query.Append($@"&mods[]={mod.Acronym}"); + req.AddParameter(@"mods[]", mod.Acronym); - return query.ToString(); + req.AddParameter(@"limit", ScoresRequested.ToString(CultureInfo.InvariantCulture)); + return req; } public bool Equals(GetScoresRequest? other) diff --git a/osu.Game/Online/API/Requests/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index cd75ff4e31..fe7ba8c33d 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -13,14 +13,14 @@ namespace osu.Game.Online.API.Requests /// public class GetUsersRequest : APIRequest { - public readonly int[] UserIds; + public const int MAX_IDS_PER_REQUEST = 50; - private const int max_ids_per_request = 50; + public readonly int[] UserIds; public GetUsersRequest(int[] userIds) { - if (userIds.Length > max_ids_per_request) - throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); + if (userIds.Length > MAX_IDS_PER_REQUEST) + throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {MAX_IDS_PER_REQUEST} IDs at once"); UserIds = userIds; } diff --git a/osu.Game/Online/API/Requests/ListTagsRequest.cs b/osu.Game/Online/API/Requests/ListTagsRequest.cs new file mode 100644 index 0000000000..ac4b1a3e2a --- /dev/null +++ b/osu.Game/Online/API/Requests/ListTagsRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class ListTagsRequest : APIRequest + { + protected override string Target => "tags"; + } +} diff --git a/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs b/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs new file mode 100644 index 0000000000..df3c9d071c --- /dev/null +++ b/osu.Game/Online/API/Requests/PatchBeatmapPackageRequest.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class PatchBeatmapPackageRequest : APIUploadRequest + { + protected override string Uri + { + get + { + // can be removed once the service has been successfully deployed to production + if (API!.Endpoints.BeatmapSubmissionServiceUrl == null) + throw new NotSupportedException("Beatmap submission not supported in this configuration!"); + + return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl!}/beatmapsets/{BeatmapSetID}"; + } + } + + protected override string Target => throw new NotSupportedException(); + + public uint BeatmapSetID { get; } + + public Dictionary FilesChanged { get; } = new Dictionary(); + + public HashSet FilesDeleted { get; } = new HashSet(); + + public PatchBeatmapPackageRequest(uint beatmapSetId) + { + BeatmapSetID = beatmapSetId; + } + + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.Method = HttpMethod.Patch; + + foreach ((string filename, byte[] content) in FilesChanged) + request.AddFile(@"filesChanged", content, filename); + + foreach (string filename in FilesDeleted) + request.AddParameter(@"filesDeleted", filename, RequestParameterType.Form); + + request.Timeout = 600_000; + return request; + } + } +} diff --git a/osu.Game/Online/API/Requests/PutBeatmapSetRequest.cs b/osu.Game/Online/API/Requests/PutBeatmapSetRequest.cs new file mode 100644 index 0000000000..ec233b5df8 --- /dev/null +++ b/osu.Game/Online/API/Requests/PutBeatmapSetRequest.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.Linq; +using System.Net.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using osu.Framework.IO.Network; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Edit.Submission; + +namespace osu.Game.Online.API.Requests +{ + public class PutBeatmapSetRequest : APIRequest + { + protected override string Uri + { + get + { + // can be removed once the service has been successfully deployed to production + if (API!.Endpoints.BeatmapSubmissionServiceUrl == null) + throw new NotSupportedException("Beatmap submission not supported in this configuration!"); + + return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl}/beatmapsets"; + } + } + + protected override string Target => throw new NotSupportedException(); + + [JsonProperty("beatmapset_id")] + public uint? BeatmapSetID { get; init; } + + [JsonProperty("beatmaps_to_create")] + public uint BeatmapsToCreate { get; init; } + + [JsonProperty("beatmaps_to_keep")] + public uint[] BeatmapsToKeep { get; init; } = []; + + [JsonProperty("target")] + public BeatmapSubmissionTarget SubmissionTarget { get; init; } + + [JsonProperty("notify_on_discussion_replies")] + public bool NotifyOnDiscussionReplies { get; init; } + + private PutBeatmapSetRequest() + { + } + + public static PutBeatmapSetRequest CreateNew(uint beatmapCount, BeatmapSubmissionSettings settings) => new PutBeatmapSetRequest + { + BeatmapsToCreate = beatmapCount, + SubmissionTarget = settings.Target.Value, + NotifyOnDiscussionReplies = settings.NotifyOnDiscussionReplies.Value, + }; + + public static PutBeatmapSetRequest UpdateExisting(uint beatmapSetId, IEnumerable beatmapsToKeep, uint beatmapsToCreate, BeatmapSubmissionSettings settings) => new PutBeatmapSetRequest + { + BeatmapSetID = beatmapSetId, + BeatmapsToKeep = beatmapsToKeep.ToArray(), + BeatmapsToCreate = beatmapsToCreate, + SubmissionTarget = settings.Target.Value, + NotifyOnDiscussionReplies = settings.NotifyOnDiscussionReplies.Value, + }; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Put; + req.ContentType = @"application/json"; + req.AddRaw(JsonConvert.SerializeObject(this)); + return req; + } + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum BeatmapSubmissionTarget + { + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetWIP))] + WIP, + + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetPending))] + Pending, + } +} diff --git a/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs new file mode 100644 index 0000000000..4ac00e28f4 --- /dev/null +++ b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.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.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class RemoveBeatmapTagRequest : APIRequest + { + public int BeatmapID { get; } + public long TagID { get; } + + public RemoveBeatmapTagRequest(int beatmapID, long tagID) + { + BeatmapID = beatmapID; + TagID = tagID; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => $@"beatmaps/{BeatmapID}/tags/{TagID}"; + } +} diff --git a/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs b/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.cs new file mode 100644 index 0000000000..de8af6a623 --- /dev/null +++ b/osu.Game/Online/API/Requests/ReplaceBeatmapPackageRequest.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 System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class ReplaceBeatmapPackageRequest : APIUploadRequest + { + protected override string Uri + { + get + { + // can be removed once the service has been successfully deployed to production + if (API!.Endpoints.BeatmapSubmissionServiceUrl == null) + throw new NotSupportedException("Beatmap submission not supported in this configuration!"); + + return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl}/beatmapsets/{BeatmapSetID}"; + } + } + + protected override string Target => throw new NotSupportedException(); + + public uint BeatmapSetID { get; } + + private readonly byte[] oszPackage; + + public ReplaceBeatmapPackageRequest(uint beatmapSetID, byte[] oszPackage) + { + this.oszPackage = oszPackage; + BeatmapSetID = beatmapSetID; + } + + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.AddFile(@"beatmapArchive", oszPackage); + request.Method = HttpMethod.Put; + request.Timeout = 600_000; + return request; + } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index e5ecfe2c99..20494a1cbf 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -32,6 +32,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"playcount")] public int PlayCount { get; set; } + [JsonProperty(@"current_user_playcount")] + public int UserPlayCount { get; set; } + [JsonProperty(@"passcount")] public int PassCount { get; set; } @@ -95,6 +98,12 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"failtimes")] public APIFailTimes? FailTimes { get; set; } + [JsonProperty(@"top_tag_ids")] + public APIBeatmapTag[]? TopTags { get; set; } + + [JsonProperty(@"current_user_tag_ids")] + public long[]? OwnTagIds { get; set; } + [JsonProperty(@"max_combo")] public int? MaxCombo { get; set; } @@ -103,6 +112,9 @@ namespace osu.Game.Online.API.Requests.Responses public double BPM { get; set; } + [JsonProperty(@"owners")] + public BeatmapOwner[] BeatmapOwners { get; set; } = Array.Empty(); + #region Implementation of IBeatmapInfo public IBeatmapMetadataInfo Metadata => (BeatmapSet as IBeatmapSetInfo)?.Metadata ?? new BeatmapMetadata(); @@ -171,5 +183,14 @@ namespace osu.Game.Online.API.Requests.Responses // ReSharper disable once NonReadonlyMemberInGetHashCode public override int GetHashCode() => OnlineID; } + + public class BeatmapOwner + { + [JsonProperty(@"id")] + public int Id { get; set; } + + [JsonProperty(@"username")] + public string Username { get; set; } = string.Empty; + } } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index d98715a42d..dc6c433f29 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -80,11 +80,37 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("artist_unicode")] public string ArtistUnicode { get; set; } = string.Empty; + /// + /// The creator of this beatmap set. + /// + /// + /// This property is set differently depending on the API endpoint. When retrieved via , + /// detailed user info is not included and the creator's ID and username are filled from the and + /// properties. For other API endpoints, this property is set by the setter. + /// public APIUser Author = new APIUser(); /// - /// Helper property to deserialize a username to . + /// Helper property to deserialize the detailed user info to /// + /// + /// This setter implements special handling for deleted users. When received a user with ID 1, it indicates + /// the original user has been deleted. In such cases, the existing data + /// (filled from and ) is preserved. For valid user, + /// the provided user info replaces the existing . + /// + [JsonProperty(@"user")] + private APIUser author + { + set => Author = value.Id != 1 ? value : Author; + } + + /// + /// The ID of the beatmap set's creator. + /// + /// + /// Helper property to deserialize the ID to . + /// [JsonProperty(@"user_id")] public int AuthorID { @@ -93,8 +119,11 @@ namespace osu.Game.Online.API.Requests.Responses } /// - /// Helper property to deserialize a username to . + /// The username of the beatmap set's creator. /// + /// + /// Helper property to deserialize the username to . + /// [JsonProperty(@"creator")] public string AuthorString { @@ -128,6 +157,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"converts")] public APIBeatmap[]? Converts { get; set; } + [JsonProperty(@"related_tags")] + public APITag[]? RelatedTags { get; set; } + private BeatmapMetadata metadata => new BeatmapMetadata { Title = Title, diff --git a/osu.Game/Online/API/Requests/Responses/APIMe.cs b/osu.Game/Online/API/Requests/Responses/APIMe.cs index 3cbddbe5e7..f1fa9d5f2b 100644 --- a/osu.Game/Online/API/Requests/Responses/APIMe.cs +++ b/osu.Game/Online/API/Requests/Responses/APIMe.cs @@ -1,13 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; +using System.Runtime.Serialization; using Newtonsoft.Json; namespace osu.Game.Online.API.Requests.Responses { public class APIMe : APIUser { - [JsonProperty("session_verified")] - public bool SessionVerified { get; set; } + [JsonProperty("session_verification_method")] + public SessionVerificationMethod? SessionVerificationMethod { get; set; } + } + + public enum SessionVerificationMethod + { + [Description("Timed one-time password")] + [EnumMember(Value = "totp")] + TimedOneTimePassword, + + [Description("E-mail")] + [EnumMember(Value = "mail")] + EmailMessage, } } diff --git a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs index 4ef39be5e5..ae73e377c4 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs @@ -10,6 +10,9 @@ namespace osu.Game.Online.API.Requests.Responses { public class APIScoresCollection { + [JsonProperty(@"score_count")] + public int ScoresCount; + [JsonProperty(@"scores")] public List Scores; diff --git a/osu.Game/Online/API/Requests/Responses/APITag.cs b/osu.Game/Online/API/Requests/Responses/APITag.cs new file mode 100644 index 0000000000..b0454fdb1d --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITag.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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APITag + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("description")] + public string Description { get; set; } = string.Empty; + + [JsonProperty("ruleset_id")] + public int? RulesetId { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APITagCollection.cs b/osu.Game/Online/API/Requests/Responses/APITagCollection.cs new file mode 100644 index 0000000000..a177699348 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITagCollection.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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APITagCollection + { + [JsonProperty("tags")] + public APITag[] Tags { get; set; } = Array.Empty(); + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APITeam.cs b/osu.Game/Online/API/Requests/Responses/APITeam.cs new file mode 100644 index 0000000000..b4fcc2d26e --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITeam.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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class APITeam + { + [JsonProperty(@"id")] + public int Id { get; set; } = 1; + + [JsonProperty(@"name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty(@"short_name")] + public string ShortName { get; set; } = string.Empty; + + [JsonProperty(@"flag_url")] + public string FlagUrl = string.Empty; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs b/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs index dac72f2488..7586f56a0e 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs @@ -39,7 +39,8 @@ namespace osu.Game.Online.API.Requests.Responses ["stable"] = new Color4(34, 153, 187, 255), ["beta40"] = new Color4(255, 221, 85, 255), ["cuttingedge"] = new Color4(238, 170, 0, 255), - [OsuGameBase.CLIENT_STREAM_NAME] = new Color4(237, 18, 33, 255), + ["lazer"] = new Color4(237, 18, 33, 255), + ["tachyon"] = new Color4(206, 0, 255, 255), ["web"] = new Color4(136, 102, 238, 255) }; diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index a829484506..6f122c58af 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -8,8 +8,8 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Bindables; using osu.Game.Extensions; +using osu.Game.Online.Metadata; using osu.Game.Users; namespace osu.Game.Online.API.Requests.Responses @@ -22,7 +22,8 @@ namespace osu.Game.Online.API.Requests.Responses /// public const int SYSTEM_USER_ID = 0; - [JsonProperty(@"id")] + // In osu-web, deleted users have a null ID. When deserializing, we ignore the null value and use 1 instead. + [JsonProperty(@"id", NullValueHandling = NullValueHandling.Ignore)] public int Id { get; set; } = 1; [JsonProperty(@"join_date")] @@ -56,9 +57,9 @@ namespace osu.Game.Online.API.Requests.Responses set => countryCodeString = value.ToString(); } - public readonly Bindable Status = new Bindable(); - - public readonly Bindable Activity = new Bindable(); + [JsonProperty(@"team")] + [CanBeNull] + public APITeam Team { get; set; } [JsonProperty(@"profile_colour")] public string Colour; @@ -112,8 +113,13 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"is_active")] public bool Active; + /// + /// From osu-web's perspective, whether a user was recently online. + /// This doesn't imply the user is online in a lazer client (may be updated from stable or web browser). + /// Use for real-time lazer online status checks. + /// [JsonProperty(@"is_online")] - public bool IsOnline; + public bool WasRecentlyOnline; [JsonProperty(@"pm_friends_only")] public bool PMFriendsOnly; diff --git a/osu.Game/Online/API/Requests/Responses/GetMyFavouriteBeatmapSetsResponse.cs b/osu.Game/Online/API/Requests/Responses/GetMyFavouriteBeatmapSetsResponse.cs new file mode 100644 index 0000000000..f728b8ea0b --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/GetMyFavouriteBeatmapSetsResponse.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class GetMyFavouriteBeatmapSetsResponse + { + [JsonProperty("beatmapset_ids")] + public int[] BeatmapSetIds { get; set; } = []; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/PutBeatmapSetResponse.cs b/osu.Game/Online/API/Requests/Responses/PutBeatmapSetResponse.cs new file mode 100644 index 0000000000..e3ec617039 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/PutBeatmapSetResponse.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 System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class PutBeatmapSetResponse + { + [JsonProperty("beatmapset_id")] + public uint BeatmapSetId { get; set; } + + [JsonProperty("beatmap_ids")] + public ICollection BeatmapIds { get; set; } = Array.Empty(); + + [JsonProperty("files")] + public ICollection Files { get; set; } = Array.Empty(); + } + + public struct BeatmapSetFile + { + [JsonProperty("filename")] + public string Filename { get; set; } + + [JsonProperty("sha2_hash")] + public string SHA2Hash { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index 36f1311f9d..58c819f391 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -87,6 +87,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("legacy_score_id")] public ulong? LegacyScoreId { get; set; } + [JsonProperty("pauses")] + public int[] Pauses { get; set; } = []; + #region osu-web API additions (not stored to database). [JsonProperty("id")] @@ -121,6 +124,12 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("ranked")] public bool Ranked { get; set; } + [JsonProperty("preserve")] + public bool Preserve { get; set; } + + [JsonProperty("processed")] + public bool Processed { get; set; } + // These properties are calculated or not relevant to any external usage. public bool ShouldSerializeID() => false; public bool ShouldSerializeUser() => false; @@ -129,6 +138,8 @@ namespace osu.Game.Online.API.Requests.Responses public bool ShouldSerializePP() => false; public bool ShouldSerializeOnlineID() => false; public bool ShouldSerializeHasReplay() => false; + public bool ShouldSerializePreserve() => false; + public bool ShouldSerializeProcessed() => false; // These fields only need to be serialised if they hold values. // Generally this is required because this model may be used by server-side components, but @@ -252,6 +263,7 @@ namespace osu.Game.Online.API.Requests.Responses Mods = score.APIMods, Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(), MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(), + Pauses = score.Pauses.ToArray(), }; } } diff --git a/osu.Game/Online/API/Requests/UnblockUserRequest.cs b/osu.Game/Online/API/Requests/UnblockUserRequest.cs new file mode 100644 index 0000000000..5f88631776 --- /dev/null +++ b/osu.Game/Online/API/Requests/UnblockUserRequest.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.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class UnblockUserRequest : APIRequest + { + public readonly int TargetId; + + public UnblockUserRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => @$"blocks/{TargetId}"; + } +} diff --git a/osu.Game/Online/API/Requests/VerificationMailFallbackRequest.cs b/osu.Game/Online/API/Requests/VerificationMailFallbackRequest.cs new file mode 100644 index 0000000000..6ea652d647 --- /dev/null +++ b/osu.Game/Online/API/Requests/VerificationMailFallbackRequest.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.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class VerificationMailFallbackRequest : APIRequest + { + protected override string Target => @"session/verify/mail-fallback"; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + return req; + } + } +} diff --git a/osu.Game/Online/API/Requests/VerifySessionRequest.cs b/osu.Game/Online/API/Requests/VerifySessionRequest.cs index b39ec5b79a..88652bce7f 100644 --- a/osu.Game/Online/API/Requests/VerifySessionRequest.cs +++ b/osu.Game/Online/API/Requests/VerifySessionRequest.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Net.Http; +using Newtonsoft.Json; using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { @@ -13,6 +15,16 @@ namespace osu.Game.Online.API.Requests public VerifySessionRequest(string verificationKey) { VerificationKey = verificationKey; + + Failure += _ => + { + string? response = WebRequest?.GetResponseString(); + if (string.IsNullOrEmpty(response)) + return; + + var responseObject = JsonConvert.DeserializeObject(response); + RequiredVerificationMethod = responseObject?.RequiredSessionVerificationMethod; + }; } protected override WebRequest CreateWebRequest() @@ -26,5 +38,13 @@ namespace osu.Game.Online.API.Requests } protected override string Target => @"session/verify"; + + public SessionVerificationMethod? RequiredVerificationMethod { get; internal set; } + + private class VerificationFailureResponse + { + [JsonProperty("method")] + public SessionVerificationMethod? RequiredSessionVerificationMethod { get; set; } + } } } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 74e85c595c..eb5d6d1b9c 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -5,10 +5,12 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Threading; @@ -64,14 +66,15 @@ namespace osu.Game.Online.Chat public IBindableList AvailableChannels => availableChannels; private readonly IAPIProvider api; - private readonly IChatClient chatClient; [Resolved] private UserLookupCache users { get; set; } private readonly IBindable apiState = new Bindable(); + private readonly IBindableList localUserBlocks = new BindableList(); private ScheduledDelegate scheduledAck; + private IChatClient chatClient = null!; private long? lastSilenceMessageId; private uint? lastSilenceId; @@ -79,14 +82,13 @@ namespace osu.Game.Online.Chat { this.api = api; - chatClient = api.GetChatClient(); - CurrentChannel.ValueChanged += currentChannelChanged; } [BackgroundDependencyLoader] private void load() { + chatClient = api.GetChatClient(); chatClient.ChannelJoined += ch => Schedule(() => joinChannel(ch)); chatClient.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false)); chatClient.NewMessages += msgs => Schedule(() => addMessages(msgs)); @@ -95,6 +97,9 @@ namespace osu.Game.Online.Chat apiState.BindTo(api.State); apiState.BindValueChanged(_ => SendAck(), true); + + localUserBlocks.BindTo(api.LocalUserState.Blocks); + localUserBlocks.BindCollectionChanged((_, args) => Schedule(() => onBlocksChanged(args))); } /// @@ -282,8 +287,7 @@ namespace osu.Game.Online.Chat // 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)); + var privateChannel = JoinedChannels.FirstOrDefault(c => c.Type == ChannelType.PM && c.Users.Count == 1 && c.Name.Equals(content, StringComparison.OrdinalIgnoreCase)); if (privateChannel != null) { @@ -312,8 +316,9 @@ namespace osu.Game.Online.Chat private void addMessages(List messages) { var channels = JoinedChannels.ToList(); + var blockedUserIds = localUserBlocks.Select(b => b.TargetID).ToList(); - foreach (var group in messages.GroupBy(m => m.ChannelId)) + foreach (var group in messages.Where(m => !blockedUserIds.Contains(m.SenderId)).GroupBy(m => m.ChannelId)) channels.Find(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); lastSilenceMessageId ??= messages.LastOrDefault()?.Id; @@ -411,7 +416,7 @@ namespace osu.Game.Online.Chat } /// - /// Find an existing channel instance for the provided channel. Lookup is performed basd on ID. + /// Find an existing channel instance for the provided channel. Lookup is performed based on ID. /// The provided channel may be used if an existing instance is not found. /// /// A candidate channel to be used for lookup or permanently on lookup failure. @@ -642,10 +647,24 @@ namespace osu.Game.Online.Chat api.Queue(req); } + private void onBlocksChanged(NotifyCollectionChangedEventArgs args) + { + if (args.Action != NotifyCollectionChangedAction.Add) + return; + + foreach (APIRelation newBlock in args.NewItems!) + { + foreach (var channel in joinedChannels) + channel.RemoveMessagesFromUser(newBlock.TargetID); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - chatClient?.Dispose(); + + if (chatClient.IsNotNull()) + chatClient.Dispose(); } } diff --git a/osu.Game/Online/Chat/ChannelType.cs b/osu.Game/Online/Chat/ChannelType.cs index bd628e90c4..4fb890c2cc 100644 --- a/osu.Game/Online/Chat/ChannelType.cs +++ b/osu.Game/Online/Chat/ChannelType.cs @@ -14,5 +14,6 @@ namespace osu.Game.Online.Chat Group, System, Announce, + Team, } } diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index fa107a0e43..e4baeb4838 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -56,7 +56,7 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load(OsuColour colours) { - IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; + IdleColour ??= overlayColourProvider?.Light2 ?? colours.Blue; } protected override IEnumerable EffectTargets => Parts; diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 75b161d57b..258cca2ad5 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -4,13 +4,16 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Notifications; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Online.Chat @@ -23,9 +26,15 @@ namespace osu.Game.Online.Chat [Resolved] private Clipboard clipboard { get; set; } = null!; - [Resolved(CanBeNull = true)] + [Resolved] private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + private Bindable externalLinkWarning = null!; [BackgroundDependencyLoader(true)] @@ -34,9 +43,51 @@ namespace osu.Game.Online.Chat externalLinkWarning = config.GetBindable(OsuSetting.ExternalLinkWarning); } - public void OpenUrlExternally(string url, bool bypassWarning = false) + public void OpenUrlExternally(string url, LinkWarnMode warnMode = LinkWarnMode.Default) { - if (!bypassWarning && externalLinkWarning.Value && dialogOverlay != null) + bool isTrustedDomain; + + if (url.StartsWith('/')) + { + url = $"{api.Endpoints.WebsiteUrl}{url}"; + isTrustedDomain = true; + } + else + { + isTrustedDomain = url.StartsWith(api.Endpoints.WebsiteUrl, StringComparison.Ordinal); + } + + if (!url.CheckIsValidUrl()) + { + notificationOverlay?.Post(new SimpleErrorNotification + { + Text = NotificationsStrings.UnsupportedOrDangerousUrlProtocol(url), + }); + + return; + } + + bool shouldWarn; + + switch (warnMode) + { + case LinkWarnMode.Default: + shouldWarn = externalLinkWarning.Value && !isTrustedDomain; + break; + + case LinkWarnMode.AlwaysWarn: + shouldWarn = true; + break; + + case LinkWarnMode.NeverWarn: + shouldWarn = false; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(warnMode), warnMode, null); + } + + if (dialogOverlay != null && shouldWarn) dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => clipboard.SetText(url))); else host.OpenUrlExternally(url); diff --git a/osu.Game/Online/Chat/LinkWarnMode.cs b/osu.Game/Online/Chat/LinkWarnMode.cs new file mode 100644 index 0000000000..0acd3994d8 --- /dev/null +++ b/osu.Game/Online/Chat/LinkWarnMode.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.Chat +{ + public enum LinkWarnMode + { + /// + /// Will show a dialog when opening a URL that is not on a trusted domain. + /// + Default, + + /// + /// Will always show a dialog when opening a URL. + /// + AlwaysWarn, + + /// + /// Will never show a dialog when opening a URL. + /// + NeverWarn, + } +} diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index f354eea027..9478f13074 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -129,8 +129,8 @@ namespace osu.Game.Online.Chat switch (args[0]) { - case "http": - case "https": + case @"http": + case @"https": // length > 3 since all these links need another argument to work if (args.Length > 3 && args[1].EndsWith(WebsiteRootUrl, StringComparison.OrdinalIgnoreCase)) { @@ -139,8 +139,8 @@ namespace osu.Game.Online.Chat switch (args[2]) { // old site only - case "b": - case "beatmaps": + case @"b": + case @"beatmaps": { string trimmed = mainArg.Split('?').First(); if (int.TryParse(trimmed, out int id)) @@ -149,11 +149,11 @@ namespace osu.Game.Online.Chat break; } - case "s": - case "beatmapsets": - case "d": + case @"s": + case @"beatmapsets": + case @"d": { - if (mainArg == "discussions") + if (mainArg == @"discussions") // handle discussion links externally for now return new LinkDetails(LinkAction.External, url); @@ -169,15 +169,15 @@ namespace osu.Game.Online.Chat break; } - case "u": - case "users": + case @"u": + case @"users": return getUserLink(mainArg); - case "wiki": + case @"wiki": return new LinkDetails(LinkAction.OpenWiki, string.Join('/', args.Skip(3))); - case "home": - if (mainArg != "changelog") + case @"home": + if (mainArg != @"changelog") // handle link other than changelog as external for now return new LinkDetails(LinkAction.External, url); @@ -192,13 +192,26 @@ namespace osu.Game.Online.Chat return new LinkDetails(LinkAction.OpenChangelog, $"{args[4]}/{args[5]}"); } + break; + + case @"multiplayer": + if (mainArg != @"rooms") + return new LinkDetails(LinkAction.External, url); + + if (args.Length == 5) + { + // https://osu.ppy.sh/multiplayer/rooms/{id} + // route used for both multiplayer and playlists + return new LinkDetails(LinkAction.JoinRoom, args[4]); + } + break; } } break; - case "osu": + case @"osu": // every internal link also needs some kind of argument if (args.Length < 3) break; @@ -207,38 +220,39 @@ namespace osu.Game.Online.Chat switch (args[1]) { - case "chan": + case @"chan": linkType = LinkAction.OpenChannel; break; - case "edit": + case @"edit": linkType = LinkAction.OpenEditorTimestamp; break; - case "b": + case @"b": linkType = LinkAction.OpenBeatmap; break; - case "s": - case "dl": + case @"s": + case @"dl": linkType = LinkAction.OpenBeatmapSet; break; - case "spectate": + case @"spectate": linkType = LinkAction.Spectate; break; - case "u": + case @"u": return getUserLink(args[2]); + case @"room": + linkType = LinkAction.JoinRoom; + break; + default: return new LinkDetails(LinkAction.External, url); } return new LinkDetails(linkType, HttpUtility.UrlDecode(args[2])); - - case "osump": - return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); } return new LinkDetails(LinkAction.External, url); @@ -337,7 +351,7 @@ namespace osu.Game.Online.Chat OpenBeatmapSet, OpenChannel, OpenEditorTimestamp, - JoinMultiplayerMatch, + JoinRoom, Spectate, OpenUserProfile, SearchBeatmapSet, diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 56f490cb21..4e17a5e28a 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -8,18 +8,20 @@ using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Graphics; -using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Online.Chat { @@ -37,6 +39,9 @@ namespace osu.Game.Online.Chat [Resolved] private ChannelManager channelManager { get; set; } + [Resolved] + private IAPIProvider api { get; set; } + [Resolved] private GameHost host { get; set; } @@ -47,19 +52,19 @@ namespace osu.Game.Online.Chat private readonly IBindableList joinedChannels = new BindableList(); [BackgroundDependencyLoader] - private void load(OsuConfigManager config, IAPIProvider api) + private void load(OsuConfigManager config) { notifyOnUsername = config.GetBindable(OsuSetting.NotifyOnUsernameMentioned); notifyOnPrivateMessage = config.GetBindable(OsuSetting.NotifyOnPrivateMessage); - - localUser.BindTo(api.LocalUser); - joinedChannels.BindTo(channelManager.JoinedChannels); } protected override void LoadComplete() { base.LoadComplete(); joinedChannels.BindCollectionChanged(channelsChanged, true); + + localUser.BindTo(api.LocalUser); + joinedChannels.BindTo(channelManager.JoinedChannels); } private void channelsChanged(object sender, NotifyCollectionChangedEventArgs e) @@ -133,59 +138,105 @@ namespace osu.Game.Online.Chat private void checkForMentions(Channel channel, Message message) { - if (!notifyOnUsername.Value || !CheckContainsUsername(message.Content, localUser.Value.Username)) return; + if (!notifyOnUsername.Value) + return; - notifications.Post(new MentionNotification(message, channel)); + var match = MatchUsername(message.Content, localUser.Value.Username); + if (!match.Success) + return; + + notifications.Post(new MentionNotification(message, channel, match)); } /// /// Checks if mentions . /// This will match against the case where underscores are used instead of spaces (which is how osu-stable handles usernames with spaces). /// - public static bool CheckContainsUsername(string message, string username) + public static Match MatchUsername(string message, string username) { string fullName = Regex.Escape(username); string underscoreName = Regex.Escape(username.Replace(' ', '_')); - return Regex.IsMatch(message, $@"(^|\W)({fullName}|{underscoreName})($|\W)", RegexOptions.IgnoreCase); + return Regex.Match(message, $@"(^|\W)({fullName}|{underscoreName})($|\W)", RegexOptions.IgnoreCase); } - public partial class PrivateMessageNotification : HighlightMessageNotification + private const int truncate_length = 60; + + public partial class PrivateMessageNotification : UserAvatarNotification { + private readonly Message message; + private readonly Channel channel; + public PrivateMessageNotification(Message message, Channel channel) - : base(message, channel) - { - Icon = FontAwesome.Solid.Envelope; - Text = NotificationsStrings.PrivateMessageReceived(message.Sender.Username); - } - } - - public partial class MentionNotification : HighlightMessageNotification - { - public MentionNotification(Message message, Channel channel) - : base(message, channel) - { - Icon = FontAwesome.Solid.At; - Text = NotificationsStrings.YourNameWasMentioned(message.Sender.Username); - } - } - - public abstract partial class HighlightMessageNotification : SimpleNotification - { - public override string PopInSampleName => "UI/notification-mention"; - - protected HighlightMessageNotification(Message message, Channel channel) + : base(message.Sender) { this.message = message; this.channel = channel; } + [BackgroundDependencyLoader] + private void load(ChatOverlay chatOverlay, INotificationOverlay notificationOverlay, OverlayColourProvider colourProvider) + { + TextFlow.AddText(NotificationsStrings.ItemChannelChannelDefault.ToUpper(), s => s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.Bold)); + TextFlow.NewLine(); + TextFlow.AddText($"{message.Sender.Username}", s => + { + s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold); + s.Colour = colourProvider.Content2; + }); + TextFlow.AddParagraph($"\"{message.Content.Truncate(truncate_length)}\""); + + Avatar.Colour = OsuColour.Gray(0.4f); + Icon = FontAwesome.Solid.Comments; + + Activated = delegate + { + notificationOverlay.Hide(); + chatOverlay.HighlightMessage(message, channel); + return true; + }; + } + } + + public partial class MentionNotification : UserAvatarNotification + { + public override string PopInSampleName => "UI/notification-mention"; + private readonly Message message; private readonly Channel channel; + private readonly Match match; + + public MentionNotification(Message message, Channel channel, Match match) + : base(message.Sender) + { + this.message = message; + this.channel = channel; + this.match = match; + } [BackgroundDependencyLoader] - private void load(OsuColour colours, ChatOverlay chatOverlay, INotificationOverlay notificationOverlay) + private void load(ChatOverlay chatOverlay, INotificationOverlay notificationOverlay, OverlayColourProvider colourProvider) { - IconContent.Colour = colours.PurpleDark; + TextFlow.AddText(Localisation.NotificationsStrings.Mention.ToUpper(), s => s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.Bold)); + TextFlow.NewLine(); + TextFlow.AddText($"{message.Sender.Username} in {channel.Name}", s => + { + s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold); + s.Colour = colourProvider.Content2; + }); + + int start = match.Index; + int end = match.Index + match.Length; + + TextFlow.AddParagraph($"\"{message.Content[..start].Truncate(truncate_length / 2, "…", from: TruncateFrom.Left)}"); + TextFlow.AddText(message.Content[start..end], s => + { + s.Font = s.Font.With(weight: FontWeight.SemiBold); + s.Colour = colourProvider.Colour0; + }); + TextFlow.AddText($"{message.Content[end..].Truncate(truncate_length / 2, "…", from: TruncateFrom.Right)}\""); + + Avatar.Colour = OsuColour.Gray(0.4f); + Icon = FontAwesome.Solid.At; Activated = delegate { diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs index 0e6f6f0bf6..43452a768c 100644 --- a/osu.Game/Online/Chat/NowPlayingCommand.cs +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -33,6 +34,7 @@ namespace osu.Game.Online.Chat private IBindable currentRuleset { get; set; } = null!; private readonly Channel? target; + private IBindable userActivity = null!; /// /// Creates a new to post the currently-playing beatmap to a parenting . @@ -43,6 +45,12 @@ namespace osu.Game.Online.Chat this.target = target; } + [BackgroundDependencyLoader] + private void load(SessionStatics session) + { + userActivity = session.GetBindable(Static.UserOnlineActivity); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -52,7 +60,7 @@ namespace osu.Game.Online.Chat int beatmapOnlineID; string beatmapDisplayTitle; - switch (api.Activity.Value) + switch (userActivity.Value) { case UserActivity.InGame game: verb = "playing"; @@ -87,19 +95,19 @@ namespace osu.Game.Online.Chat string getBeatmapPart() { - return beatmapOnlineID > 0 ? $"[{api.WebsiteRootUrl}/b/{beatmapOnlineID} {beatmapDisplayTitle}]" : beatmapDisplayTitle; + return beatmapOnlineID > 0 ? $"[{api.Endpoints.WebsiteUrl}/b/{beatmapOnlineID} {beatmapDisplayTitle}]" : beatmapDisplayTitle; } string getRulesetPart() { - if (api.Activity.Value is not UserActivity.InGame) return string.Empty; + if (userActivity.Value is not UserActivity.InGame) return string.Empty; return $"<{currentRuleset.Value.Name}>"; } string getModPart() { - if (api.Activity.Value is not UserActivity.InGame) return string.Empty; + if (userActivity.Value is not UserActivity.InGame) return string.Empty; if (selectedMods.Value.Count == 0) { diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index 187191d232..667ef072a9 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.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. -#nullable disable - using System; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -21,18 +20,18 @@ using osuTK.Input; namespace osu.Game.Online.Chat { /// - /// Display a chat channel in an insolated region. + /// Display a chat channel in an isolated region. /// public partial class StandAloneChatDisplay : CompositeDrawable { [Cached] - public readonly Bindable Channel = new Bindable(); + public readonly Bindable Channel = new Bindable(); - protected readonly ChatTextBox TextBox; + protected readonly ChatTextBox? TextBox; - private ChannelManager channelManager; + private ChannelManager? channelManager; - private StandAloneDrawableChannel drawableChannel; + private StandAloneDrawableChannel? drawableChannel; private readonly bool postingTextBox; @@ -93,6 +92,8 @@ namespace osu.Game.Online.Chat private void postMessage(TextBox sender, bool newText) { + Debug.Assert(TextBox != null); + string text = TextBox.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) @@ -106,9 +107,9 @@ namespace osu.Game.Online.Chat TextBox.Text = string.Empty; } - protected virtual ChatLine CreateMessage(Message message) => new StandAloneMessage(message); + protected virtual ChatLine? CreateMessage(Message message) => new StandAloneMessage(message); - private void channelChanged(ValueChangedEvent e) + private void channelChanged(ValueChangedEvent e) { drawableChannel?.Expire(); @@ -128,8 +129,8 @@ namespace osu.Game.Online.Chat public partial class ChatTextBox : HistoryTextBox { - public Action Focus; - public Action FocusLost; + public Action? Focus; + public Action? FocusLost; protected override bool OnKeyDown(KeyDownEvent e) { @@ -171,14 +172,14 @@ namespace osu.Game.Online.Chat public partial class StandAloneDrawableChannel : DrawableChannel { - public Func CreateChatLineAction; + public Func? CreateChatLineAction; public StandAloneDrawableChannel(Channel channel) : base(channel) { } - protected override ChatLine CreateChatLine(Message m) => CreateChatLineAction(m); + protected override ChatLine? CreateChatLine(Message m) => CreateChatLineAction?.Invoke(m) ?? null; protected override DaySeparator CreateDaySeparator(DateTimeOffset time) => new StandAloneDaySeparator(time); } diff --git a/osu.Game/Online/DevelopmentEndpointConfiguration.cs b/osu.Game/Online/DevelopmentEndpointConfiguration.cs index 5f3c353f4d..e36e36ee9f 100644 --- a/osu.Game/Online/DevelopmentEndpointConfiguration.cs +++ b/osu.Game/Online/DevelopmentEndpointConfiguration.cs @@ -7,12 +7,13 @@ namespace osu.Game.Online { public DevelopmentEndpointConfiguration() { - WebsiteRootUrl = APIEndpointUrl = @"https://dev.ppy.sh"; + WebsiteUrl = APIUrl = @"https://dev.ppy.sh"; APIClientSecret = @"3LP2mhUrV89xxzD1YKNndXHEhWWCRLPNKioZ9ymT"; APIClientID = "5"; - SpectatorEndpointUrl = $@"{APIEndpointUrl}/signalr/spectator"; - MultiplayerEndpointUrl = $@"{APIEndpointUrl}/signalr/multiplayer"; - MetadataEndpointUrl = $@"{APIEndpointUrl}/signalr/metadata"; + SpectatorUrl = $@"{APIUrl}/signalr/spectator"; + MultiplayerUrl = $@"{APIUrl}/signalr/multiplayer"; + MetadataUrl = $@"{APIUrl}/signalr/metadata"; + BeatmapSubmissionServiceUrl = $@"{APIUrl}/beatmap-submission"; } } } diff --git a/osu.Game/Online/EndpointConfiguration.cs b/osu.Game/Online/EndpointConfiguration.cs index bd3c945124..2d5ea32345 100644 --- a/osu.Game/Online/EndpointConfiguration.cs +++ b/osu.Game/Online/EndpointConfiguration.cs @@ -8,16 +8,6 @@ namespace osu.Game.Online /// public class EndpointConfiguration { - /// - /// The base URL for the website. - /// - public string WebsiteRootUrl { get; set; } = string.Empty; - - /// - /// The endpoint for the main (osu-web) API. - /// - public string APIEndpointUrl { get; set; } = string.Empty; - /// /// The OAuth client secret. /// @@ -28,19 +18,34 @@ namespace osu.Game.Online /// public string APIClientID { get; set; } = string.Empty; + /// + /// The base URL for the website. Does not include a trailing slash. + /// + public string WebsiteUrl { get; set; } = string.Empty; + + /// + /// The endpoint for the main (osu-web) API. Does not include a trailing slash. + /// + public string APIUrl { get; set; } = string.Empty; + + /// + /// The root URL for the service handling beatmap submission. Does not include a trailing slash. + /// + public string? BeatmapSubmissionServiceUrl { get; set; } + /// /// The endpoint for the SignalR spectator server. /// - public string SpectatorEndpointUrl { get; set; } = string.Empty; + public string SpectatorUrl { get; set; } = string.Empty; /// /// The endpoint for the SignalR multiplayer server. /// - public string MultiplayerEndpointUrl { get; set; } = string.Empty; + public string MultiplayerUrl { get; set; } = string.Empty; /// /// The endpoint for the SignalR metadata server. /// - public string MetadataEndpointUrl { get; set; } = string.Empty; + public string MetadataUrl { get; set; } = string.Empty; } } diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs new file mode 100644 index 0000000000..5ba5b48e59 --- /dev/null +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -0,0 +1,252 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Users; +using osuTK.Graphics; + +namespace osu.Game.Online +{ + public partial class FriendPresenceNotifier : Component + { + [Resolved] + private INotificationOverlay notifications { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private readonly Bindable notifyOnFriendPresenceChange = new BindableBool(); + + private readonly IBindableList friends = new BindableList(); + private readonly IBindableDictionary friendPresences = new BindableDictionary(); + + private readonly HashSet onlineAlertQueue = new HashSet(); + private readonly HashSet offlineAlertQueue = new HashSet(); + + private double? lastOnlineAlertTime; + private double? lastOfflineAlertTime; + + protected override void LoadComplete() + { + base.LoadComplete(); + + config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); + + friends.BindTo(api.LocalUserState.Friends); + friends.BindCollectionChanged(onFriendsChanged, true); + + friendPresences.BindTo(metadataClient.FriendPresences); + friendPresences.BindCollectionChanged(onFriendPresenceChanged, true); + } + + protected override void Update() + { + base.Update(); + + alertOnlineUsers(); + alertOfflineUsers(); + } + + private void onFriendsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (APIRelation friend in e.NewItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + if (friendPresences.TryGetValue(friend.TargetID, out _)) + markUserOnline(user); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (APIRelation friend in e.OldItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + onlineAlertQueue.Remove(user); + offlineAlertQueue.Remove(user); + } + + break; + } + } + + private void onFriendPresenceChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach ((int friendId, _) in e.NewItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOnline(user); + } + + break; + + case NotifyDictionaryChangedAction.Remove: + foreach ((int friendId, _) in e.OldItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOffline(user); + } + + break; + } + } + + private void markUserOnline(APIUser user) + { + if (!offlineAlertQueue.Remove(user)) + { + onlineAlertQueue.Add(user); + lastOnlineAlertTime ??= Time.Current; + } + } + + private void markUserOffline(APIUser user) + { + if (!onlineAlertQueue.Remove(user)) + { + offlineAlertQueue.Add(user); + lastOfflineAlertTime ??= Time.Current; + } + } + + private void alertOnlineUsers() + { + if (onlineAlertQueue.Count == 0) + return; + + if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) + return; + + if (!notifyOnFriendPresenceChange.Value) + { + lastOnlineAlertTime = null; + return; + } + + notifications.Post(new FriendOnlineNotification(onlineAlertQueue.ToArray())); + + onlineAlertQueue.Clear(); + lastOnlineAlertTime = null; + } + + private void alertOfflineUsers() + { + if (offlineAlertQueue.Count == 0) + return; + + if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) + return; + + if (!notifyOnFriendPresenceChange.Value) + { + lastOfflineAlertTime = null; + return; + } + + notifications.Post(new FriendOfflineNotification(offlineAlertQueue.ToArray())); + + offlineAlertQueue.Clear(); + lastOfflineAlertTime = null; + } + + public partial class FriendOnlineNotification : UserAvatarNotification + { + private readonly ICollection users; + + public FriendOnlineNotification(ICollection users) + : base(users.Count == 1 ? users.Single() : null) + { + this.users = users; + + Transient = true; + IsImportant = false; + Text = $"Online: {string.Join(@", ", users.Select(u => u.Username))}"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, ChannelManager channelManager, ChatOverlay chatOverlay) + { + if (users.Count > 1) + { + Icon = FontAwesome.Solid.User; + IconColour = colours.GrayD; + } + else + { + Activated = () => + { + channelManager.OpenPrivateChannel(users.Single()); + chatOverlay.Show(); + + return true; + }; + } + } + + public override string PopInSampleName => "UI/notification-friend-online"; + } + + public partial class FriendOfflineNotification : UserAvatarNotification + { + private readonly ICollection users; + + public FriendOfflineNotification(ICollection users) + : base(users.Count == 1 ? users.Single() : null) + { + this.users = users; + + Transient = true; + IsImportant = false; + Text = $"Offline: {string.Join(@", ", users.Select(u => u.Username))}"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Icon = FontAwesome.Solid.UserSlash; + + if (users.Count == 1) + Avatar.Colour = Color4.White.Opacity(0.25f); + else + IconColour = colours.Gray3; + } + + public override string PopInSampleName => "UI/notification-friend-offline"; + } + } +} diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 9288a32052..e6391e8810 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -2,14 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Net; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; using osu.Framework; using osu.Game.Online.API; @@ -29,7 +26,6 @@ namespace osu.Game.Online private readonly string endpoint; private readonly string versionHash; - private readonly bool preferMessagePack; /// /// The current connection opened by this connector. @@ -43,14 +39,12 @@ 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. - /// Whether to use MessagePack for serialisation if available on this platform. - public HubClientConnector(string clientName, string endpoint, IAPIProvider api, string versionHash, bool preferMessagePack = true) + public HubClientConnector(string clientName, string endpoint, IAPIProvider api, string versionHash) : base(api) { ClientName = clientName; this.endpoint = endpoint; this.versionHash = versionHash; - this.preferMessagePack = preferMessagePack; // Automatically start these connections. Start(); @@ -78,26 +72,10 @@ namespace osu.Game.Online options.Headers.Add(CLIENT_SESSION_ID_HEADER, API.SessionIdentifier.ToString()); }); - if (RuntimeFeature.IsDynamicCodeCompiled && preferMessagePack) + builder.AddMessagePackProtocol(options => { - 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; - options.PayloadSerializerSettings.Converters = new List - { - new SignalRDerivedTypeWorkaroundJsonConverter(), - }; - }); - } + options.SerializerOptions = SignalRUnionWorkaroundResolver.OPTIONS; + }); var newConnection = builder.Build(); diff --git a/osu.Game/Online/IHubClientConnector.cs b/osu.Game/Online/IHubClientConnector.cs index 052972e6b4..0f78ba2c5d 100644 --- a/osu.Game/Online/IHubClientConnector.cs +++ b/osu.Game/Online/IHubClientConnector.cs @@ -28,7 +28,7 @@ namespace osu.Game.Online /// /// Invoked whenever a new hub connection is built, to configure it before it's started. /// - public Action? ConfigureConnection { get; set; } + Action? ConfigureConnection { get; set; } /// /// Forcefully disconnects the client from the server. diff --git a/osu.Game/Online/Leaderboards/DrawableRank.cs b/osu.Game/Online/Leaderboards/DrawableRank.cs index 3bc80c8b37..f4f4165c7f 100644 --- a/osu.Game/Online/Leaderboards/DrawableRank.cs +++ b/osu.Game/Online/Leaderboards/DrawableRank.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -53,9 +52,9 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.Centre, Spacing = new Vector2(-3, 0), Padding = new MarginPadding { Top = 5 }, - Colour = GetRankNameColour(rank), + Colour = GetRankLetterColour(rank), Font = OsuFont.Numeric.With(size: 25), - Text = GetRankName(rank), + Text = GetRankLetter(rank), ShadowColour = Color4.Black.Opacity(0.3f), ShadowOffset = new Vector2(0, 0.08f), Shadow = true, @@ -65,12 +64,29 @@ namespace osu.Game.Online.Leaderboards }; } - public static string GetRankName(ScoreRank rank) => rank.GetDescription().TrimEnd('+'); + /// + /// Returns letters to be shown in places where ranks are shown on a badge or similar to the user. + /// + public static string GetRankLetter(ScoreRank rank) + { + switch (rank) + { + case ScoreRank.SH: + return @"S"; + + case ScoreRank.X: + case ScoreRank.XH: + return @"SS"; + + default: + return rank.ToString(); + } + } /// /// Retrieves the grade text colour. /// - public static ColourInfo GetRankNameColour(ScoreRank rank) + public static ColourInfo GetRankLetterColour(ScoreRank rank) { switch (rank) { diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index d76da54adf..021a2b3959 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -356,6 +356,9 @@ namespace osu.Game.Online.Leaderboards case LeaderboardState.NotSupporter: return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard); + case LeaderboardState.NoTeam: + return new MessagePlaceholder(LeaderboardStrings.NoTeam); + case LeaderboardState.Retrieving: return null; @@ -375,8 +378,8 @@ namespace osu.Game.Online.Leaderboards { base.UpdateAfterChildren(); - float fadeBottom = scrollContainer.Current + scrollContainer.DrawHeight; - float fadeTop = scrollContainer.Current + LeaderboardScore.HEIGHT; + float fadeBottom = (float)(scrollContainer.Current + scrollContainer.DrawHeight); + float fadeTop = (float)(scrollContainer.Current + LeaderboardScore.HEIGHT); if (!scrollContainer.IsScrolledToEnd()) fadeBottom -= LeaderboardScore.HEIGHT; diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs new file mode 100644 index 0000000000..de53acc3f6 --- /dev/null +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Development; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using Realms; + +namespace osu.Game.Online.Leaderboards +{ + public partial class LeaderboardManager : Component + { + /// + /// The latest leaderboard scores fetched by the criteria in . + /// + public IBindable Scores => scores; + + private readonly Bindable scores = new Bindable(); + + public LeaderboardCriteria? CurrentCriteria { get; private set; } + + private IDisposable? localScoreSubscription; + private GetScoresRequest? inFlightOnlineRequest; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + /// + /// Fetch leaderboard content with the new criteria specified in the background. + /// On completion, will be updated with the results from this call (unless a more recent call with a different criteria has completed). + /// + public void FetchWithCriteria(LeaderboardCriteria newCriteria, bool forceRefresh = false) + { + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException(@$"{nameof(FetchWithCriteria)} must be called from the update thread."); + + if (!forceRefresh && CurrentCriteria?.Equals(newCriteria) == true && scores.Value?.FailState == null) + return; + + CurrentCriteria = newCriteria; + localScoreSubscription?.Dispose(); + inFlightOnlineRequest?.Cancel(); + scores.Value = null; + + if (newCriteria.Beatmap == null || newCriteria.Ruleset == null) + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoneSelected); + return; + } + + switch (newCriteria.Scope) + { + case BeatmapLeaderboardScope.Local: + { + localScoreSubscription = realm.RegisterForNotifications(r => + r.All().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" + + $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + + $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" + + $" AND {nameof(ScoreInfo.DeletePending)} == false" + , newCriteria.Beatmap.ID, newCriteria.Ruleset.ShortName), localScoresChanged); + return; + } + + default: + { + if (newCriteria.Sorting != LeaderboardSortMode.Score) + throw new NotSupportedException($@"Requesting online scores with a {nameof(LeaderboardSortMode)} other than {nameof(LeaderboardSortMode.Score)} is not supported"); + + if (!api.IsLoggedIn) + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn); + return; + } + + if (!newCriteria.Ruleset.IsLegacyRuleset()) + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.RulesetUnavailable); + return; + } + + if (newCriteria.Beatmap.OnlineID <= 0 || newCriteria.Beatmap.Status <= BeatmapOnlineStatus.Pending) + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.BeatmapUnavailable); + return; + } + + if ((newCriteria.Scope.RequiresSupporter(newCriteria.ExactMods != null)) && !api.LocalUser.Value.IsSupporter) + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotSupporter); + return; + } + + if (newCriteria.Scope == BeatmapLeaderboardScope.Team && api.LocalUser.Value.Team == null) + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoTeam); + return; + } + + IReadOnlyList? requestMods = null; + + if (newCriteria.ExactMods != null) + { + if (!newCriteria.ExactMods.Any()) + // add nomod for the request + requestMods = new Mod[] { new ModNoMod() }; + else + requestMods = newCriteria.ExactMods; + } + + var newRequest = new GetScoresRequest(newCriteria.Beatmap, newCriteria.Ruleset, newCriteria.Scope, requestMods); + newRequest.Success += response => + { + if (inFlightOnlineRequest != null && !newRequest.Equals(inFlightOnlineRequest)) + return; + + var result = LeaderboardScores.Success + ( + response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap)) + .OrderByTotalScore() + .Select((s, idx) => + { + s.Position = idx + 1; + return s; + }) + .ToArray(), + scoresRequested: newRequest.ScoresRequested, + totalScores: response.ScoresCount, + response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap) + ); + inFlightOnlineRequest = null; + scores.Value = result; + }; + newRequest.Failure += ex => + { + Logger.Log($@"Failed to fetch leaderboards when displaying results: {ex}", LoggingTarget.Network); + if (ex is not OperationCanceledException) + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure); + }; + + api.Queue(inFlightOnlineRequest = newRequest); + break; + } + } + } + + private void localScoresChanged(IRealmCollection sender, ChangeSet? changes) + { + Debug.Assert(CurrentCriteria != null); + + // This subscription may fire from changes to linked beatmaps, which we don't care about. + // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. + if (changes?.HasCollectionChanges() == false) + return; + + var newScores = sender.AsEnumerable(); + + if (CurrentCriteria.ExactMods != null) + { + if (!CurrentCriteria.ExactMods.Any()) + { + // we need to filter out all scores that have any mods to get all local nomod scores + newScores = newScores.Where(s => !s.Mods.Any()); + } + else + { + // otherwise find all the scores that have all of the currently selected mods (similar to how web applies mod filters) + // we're creating and using a string HashSet representation of selected mods so that it can be translated into the DB query itself + var selectedMods = CurrentCriteria.ExactMods.Select(m => m.Acronym).ToHashSet(); + + newScores = newScores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym))); + } + } + + newScores = newScores.Detach().OrderByCriteria(CurrentCriteria.Sorting); + + var newScoresArray = newScores.ToArray(); + scores.Value = LeaderboardScores.Success(newScoresArray, scoresRequested: newScoresArray.Length, totalScores: newScoresArray.Length, null); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + localScoreSubscription?.Dispose(); + } + } + + public record LeaderboardCriteria( + BeatmapInfo? Beatmap, + RulesetInfo? Ruleset, + BeatmapLeaderboardScope Scope, + Mod[]? ExactMods, + LeaderboardSortMode Sorting = LeaderboardSortMode.Score + ); + + public record LeaderboardScores + { + /// + /// The collection of all scores received through the leaderboard lookup. + /// + public ICollection TopScores { get; } + + /// + /// The number of scores which was requested. + /// Used to determine whether the returned leaderboard can be judged to be a partial or full leaderboard + /// (i.e. whether contains all scores that it could ever contain). + /// + public int ScoresRequested { get; } + + /// + /// The number of all scores that exist on the leaderboard. + /// + public int TotalScores { get; } + + public bool IsPartial => ScoresRequested < TotalScores; + + /// + /// The local user's best score. + /// + public ScoreInfo? UserScore { get; } + + /// + /// The failure state that occurred when attempting to retrieve the leaderboard. + /// + public LeaderboardFailState? FailState { get; } + + public IEnumerable AllScores + { + get + { + foreach (var score in TopScores) + yield return score; + + if (UserScore != null && TopScores.All(topScore => !topScore.Equals(UserScore) && !topScore.MatchesOnlineID(UserScore))) + yield return UserScore; + } + } + + private LeaderboardScores(ICollection topScores, int scoresRequested, int totalScores, ScoreInfo? userScore, LeaderboardFailState? failState) + { + TopScores = topScores; + ScoresRequested = scoresRequested; + TotalScores = totalScores; + UserScore = userScore; + FailState = failState; + } + + public static LeaderboardScores Success(ICollection topScores, int scoresRequested, int totalScores, ScoreInfo? userScore) + => new LeaderboardScores(topScores, scoresRequested, totalScores, userScore, null); + + public static LeaderboardScores Failure(LeaderboardFailState failState) + => new LeaderboardScores([], scoresRequested: 0, totalScores: 0, null, failState); + } + + public enum LeaderboardFailState + { + NetworkFailure = -1, + BeatmapUnavailable = -2, + RulesetUnavailable = -3, + NoneSelected = -4, + NotLoggedIn = -5, + NotSupporter = -6, + NoTeam = -7 + } +} diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 5651f01645..bc617cae80 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -54,6 +54,7 @@ namespace osu.Game.Online.Leaderboards private readonly int? rank; private readonly bool isOnlineScope; + private readonly bool highlightFriend; private Box background; private Container content; @@ -86,12 +87,13 @@ namespace osu.Game.Online.Leaderboards [Resolved] private ScoreManager scoreManager { get; set; } = null!; - public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true) + public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true, bool highlightFriend = true) { Score = score; this.rank = rank; this.isOnlineScope = isOnlineScope; + this.highlightFriend = highlightFriend; RelativeSizeAxes = Axes.X; Height = HEIGHT; @@ -101,6 +103,7 @@ namespace osu.Game.Online.Leaderboards private void load(IAPIProvider api, OsuColour colour) { var user = Score.User; + bool isUserFriend = api.LocalUserState.Friends.Any(friend => friend.TargetID == user.OnlineID); statisticsLabels = GetStatistics(Score).Select(s => new ScoreComponentLabel(s)).ToList(); @@ -129,7 +132,7 @@ namespace osu.Game.Online.Leaderboards background = new Box { RelativeSizeAxes = Axes.Both, - Colour = user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black, + Colour = (highlightFriend && isUserFriend) ? colour.Yellow : (user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black), Alpha = background_alpha, }, }, @@ -177,6 +180,7 @@ namespace osu.Game.Online.Leaderboards Height = 28, Direction = FillDirection.Horizontal, Spacing = new Vector2(10f, 0f), + Margin = new MarginPadding { Bottom = -2 }, Children = new Drawable[] { flagBadgeAndDateContainer = new FillFlowContainer @@ -186,7 +190,7 @@ namespace osu.Game.Online.Leaderboards RelativeSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, Spacing = new Vector2(5f, 0f), - Width = 87f, + Width = 130f, Masking = true, Children = new Drawable[] { @@ -196,6 +200,12 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.CentreLeft, Size = new Vector2(28, 20), }, + new UpdateableTeamFlag(user.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(40, 20), + }, new DateLabel(Score.Date) { Anchor = Anchor.CentreLeft, @@ -203,15 +213,6 @@ namespace osu.Game.Online.Leaderboards }, }, }, - new FillFlowContainer - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Left = edge_margin }, - Children = statisticsLabels - }, }, }, }, @@ -231,6 +232,7 @@ namespace osu.Game.Online.Leaderboards GlowColour = Color4Extensions.FromHex(@"83ccfa"), Current = scoreManager.GetBindableTotalScoreString(Score), Font = OsuFont.Numeric.With(size: 23), + Margin = new MarginPadding { Top = 1 }, }, RankContainer = new Container { @@ -247,13 +249,33 @@ namespace osu.Game.Online.Leaderboards }, }, }, - modsContainer = new FillFlowContainer + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = edge_margin }, + Children = statisticsLabels + }, + modsContainer = new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(-10, 0), + Direction = FillDirection.Horizontal, + ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { Scale = new Vector2(0.34f) }) + }, + } }, }, }, @@ -321,7 +343,7 @@ namespace osu.Game.Online.Leaderboards private partial class ScoreComponentLabel : Container, IHasTooltip { - private const float icon_size = 20; + private const float icon_size = 16; private readonly FillFlowContainer content; public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); @@ -337,7 +359,7 @@ namespace osu.Game.Online.Leaderboards { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Right = 10 }, + Padding = new MarginPadding { Right = 5 }, Children = new Drawable[] { new Container @@ -372,7 +394,8 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Text = statistic.Value, - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold, fixedWidth: true) + Spacing = new Vector2(-1, 0), + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold, fixedWidth: true) }, }, }; @@ -391,7 +414,7 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.Centre, Origin = Anchor.Centre, Font = OsuFont.GetFont(size: 20, italics: true), - Text = rank == null ? "-" : rank.Value.FormatRank() + Text = rank?.FormatRank() ?? "-" }; } @@ -403,10 +426,10 @@ namespace osu.Game.Online.Leaderboards public DateLabel(DateTimeOffset date) : base(date) { - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold, italics: true); + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold); } - protected override string Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); + protected override LocalisableString Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); } public class LeaderboardScoreStatistic @@ -429,11 +452,14 @@ namespace osu.Game.Online.Leaderboards { List items = new List(); - if (Score.Mods.Length > 0 && songSelect != null) - items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods)); + // system mods should never be copied across regardless of anything. + var copyableMods = Score.Mods.Where(m => m.Type != ModType.System).ToArray(); + + if (copyableMods.Length > 0 && songSelect != null) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = copyableMods)); if (Score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{Score.OnlineID}"))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); if (Score.Files.Count > 0) { diff --git a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs index ed3ee4d45e..ee497bf3fd 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Containers; @@ -219,7 +220,7 @@ namespace osu.Game.Online.Leaderboards } }; - string description = mod.SettingDescription; + string description = string.Join(", ", mod.SettingDescription.Select(svp => $"{svp.setting}: {svp.value}")); if (!string.IsNullOrEmpty(description)) { @@ -227,7 +228,7 @@ namespace osu.Game.Online.Leaderboards { RelativeSizeAxes = Axes.Y, Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), - Text = mod.SettingDescription, + Text = description, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, Margin = new MarginPadding { Top = 1 }, diff --git a/osu.Game/Online/Leaderboards/LeaderboardState.cs b/osu.Game/Online/Leaderboards/LeaderboardState.cs index 6b07500a98..b0b45ef04e 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardState.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardState.cs @@ -7,12 +7,14 @@ namespace osu.Game.Online.Leaderboards { Success, Retrieving, - NetworkFailure, - BeatmapUnavailable, - RulesetUnavailable, - NoneSelected, NoScores, - NotLoggedIn, - NotSupporter, + + NetworkFailure = LeaderboardFailState.NetworkFailure, + BeatmapUnavailable = LeaderboardFailState.BeatmapUnavailable, + RulesetUnavailable = LeaderboardFailState.RulesetUnavailable, + NoneSelected = LeaderboardFailState.NoneSelected, + NotLoggedIn = LeaderboardFailState.NotLoggedIn, + NotSupporter = LeaderboardFailState.NotSupporter, + NoTeam = LeaderboardFailState.NoTeam, } } diff --git a/osu.Game/Online/Leaderboards/UpdateableRank.cs b/osu.Game/Online/Leaderboards/UpdateableRank.cs index b64fab6861..ea5a985ef7 100644 --- a/osu.Game/Online/Leaderboards/UpdateableRank.cs +++ b/osu.Game/Online/Leaderboards/UpdateableRank.cs @@ -11,7 +11,9 @@ namespace osu.Game.Online.Leaderboards { public partial class UpdateableRank : ModelBackedDrawable { - protected override double TransformDuration => 600; + private readonly bool animate; + + protected override double TransformDuration => animate ? 600 : 0; protected override bool TransformImmediately => true; public ScoreRank? Rank @@ -20,8 +22,10 @@ namespace osu.Game.Online.Leaderboards set => Model = value; } - public UpdateableRank(ScoreRank? rank = null) + public UpdateableRank(ScoreRank? rank = null, bool animate = true) { + this.animate = animate; + Rank = rank; } @@ -58,7 +62,6 @@ namespace osu.Game.Online.Leaderboards protected override TransformSequence ApplyHideTransforms(Drawable drawable) { drawable.ScaleTo(1.8f, TransformDuration, Easing.Out); - return base.ApplyHideTransforms(drawable); } } diff --git a/osu.Game/Online/LocalUserStatisticsProvider.cs b/osu.Game/Online/LocalUserStatisticsProvider.cs index 22d5788c87..061f0c7e03 100644 --- a/osu.Game/Online/LocalUserStatisticsProvider.cs +++ b/osu.Game/Online/LocalUserStatisticsProvider.cs @@ -5,10 +5,12 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Users; @@ -35,6 +37,8 @@ namespace osu.Game.Online [Resolved] private IAPIProvider api { get; set; } = null!; + private readonly IBindable localUser = new Bindable(); + private readonly Dictionary statisticsCache = new Dictionary(); /// @@ -48,7 +52,8 @@ namespace osu.Game.Online { base.LoadComplete(); - api.LocalUser.BindValueChanged(_ => + localUser.BindTo(api.LocalUser); + localUser.BindValueChanged(_ => { // queuing up requests directly on user change is unsafe, as the API status may have not been updated yet. // schedule a frame to allow the API to be in its correct state sending requests. diff --git a/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarAction.cs b/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarAction.cs new file mode 100644 index 0000000000..cab007327c --- /dev/null +++ b/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarAction.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.Online.Matchmaking.Events +{ + /// + /// An action performed on a user's avatar in a matchmaking room. + /// + public enum MatchmakingAvatarAction + { + Jump + } +} diff --git a/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarActionEvent.cs b/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarActionEvent.cs new file mode 100644 index 0000000000..187d234855 --- /dev/null +++ b/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarActionEvent.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 MessagePack; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Online.Matchmaking.Events +{ + /// + /// An action performed by a user in a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingAvatarActionEvent : MatchServerEvent + { + /// + /// The user performing the action. + /// + [Key(0)] + public int UserId { get; set; } + + /// + /// The action. + /// + [Key(1)] + public MatchmakingAvatarAction Action { get; set; } + } +} diff --git a/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarActionRequest.cs b/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarActionRequest.cs new file mode 100644 index 0000000000..abee95f0e2 --- /dev/null +++ b/osu.Game/Online/Matchmaking/Events/MatchmakingAvatarActionRequest.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; + +namespace osu.Game.Online.Matchmaking.Events +{ + /// + /// Requests to perform an action on a user's avatar in a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingAvatarActionRequest : MatchUserRequest + { + /// + /// The action. + /// + [Key(0)] + public MatchmakingAvatarAction Action { get; set; } + } +} diff --git a/osu.Game/Online/Matchmaking/IMatchmakingClient.cs b/osu.Game/Online/Matchmaking/IMatchmakingClient.cs new file mode 100644 index 0000000000..be05e3ca0d --- /dev/null +++ b/osu.Game/Online/Matchmaking/IMatchmakingClient.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; + +namespace osu.Game.Online.Matchmaking +{ + public interface IMatchmakingClient : IStatefulUserHubClient + { + /// + /// Signals that the local user was placed in the matchmaking queue. + /// + Task MatchmakingQueueJoined(); + + /// + /// Signals that the local user was removed from the matchmaking queue. + /// + Task MatchmakingQueueLeft(); + + /// + /// Signals that a match has been found and the local user is invited to it. + /// The invitation may be accepted, + /// declined, + /// or ignored - in which case it will automatically be declined after a short timeout period. + /// + Task MatchmakingRoomInvited(); + + /// + /// Signals that the matchmaking room is ready to be opened. + /// + Task MatchmakingRoomReady(long roomId, string password); + + /// + /// The matchmaking lobby status has changed. + /// + Task MatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status); + + /// + /// The matchmaking status of the current user has changed. + /// + Task MatchmakingQueueStatusChanged(MatchmakingQueueStatus status); + + /// + /// The user has raised a candidate playlist item to be played. + /// + /// The notifying user. + /// The playlist item candidate raised, or -1 as a special value that indicates a random selection. + Task MatchmakingItemSelected(int userId, long playlistItemId); + + /// + /// The user has removed a candidate playlist item. + /// + /// The notifying user. + /// The playlist item candidate removed, or -1 as a special value that indicates a random selection. + Task MatchmakingItemDeselected(int userId, long playlistItemId); + } +} diff --git a/osu.Game/Online/Matchmaking/IMatchmakingServer.cs b/osu.Game/Online/Matchmaking/IMatchmakingServer.cs new file mode 100644 index 0000000000..7641c57fe9 --- /dev/null +++ b/osu.Game/Online/Matchmaking/IMatchmakingServer.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; + +namespace osu.Game.Online.Matchmaking +{ + public interface IMatchmakingServer + { + /// + /// Retrieves all active matchmaking pools. + /// + Task GetMatchmakingPools(); + + /// + /// Joins the matchmaking lobby, allowing the local user to receive status updates. + /// + Task MatchmakingJoinLobby(); + + /// + /// Leaves the matchmaking lobby. + /// + Task MatchmakingLeaveLobby(); + + /// + /// Joins the matchmaking queue, allowing the local user to get matched up with others. + /// + Task MatchmakingJoinQueue(int poolId); + + /// + /// Leaves the matchmaking queue. + /// + Task MatchmakingLeaveQueue(); + + /// + /// Accepts a matchmaking room invitation. + /// + Task MatchmakingAcceptInvitation(); + + /// + /// Declines a matchmaking room invitation. + /// + Task MatchmakingDeclineInvitation(); + + /// + /// Raise a candidate playlist item to be played in the current round. + /// + /// The playlist item, or -1 to indicate a random selection. + Task MatchmakingToggleSelection(long playlistItemId); + + /// + /// Debug only - skips to the next stage of the matchmaking room. + /// + Task MatchmakingSkipToNextStage(); + } +} diff --git a/osu.Game/Online/Matchmaking/MatchmakingLobbyStatus.cs b/osu.Game/Online/Matchmaking/MatchmakingLobbyStatus.cs new file mode 100644 index 0000000000..9a1e083b84 --- /dev/null +++ b/osu.Game/Online/Matchmaking/MatchmakingLobbyStatus.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 MessagePack; + +namespace osu.Game.Online.Matchmaking +{ + [Serializable] + [MessagePackObject] + public class MatchmakingLobbyStatus + { + [Key(0)] + public int[] UsersInQueue { get; set; } = []; + } +} diff --git a/osu.Game/Online/Matchmaking/MatchmakingPool.cs b/osu.Game/Online/Matchmaking/MatchmakingPool.cs new file mode 100644 index 0000000000..3f256d5251 --- /dev/null +++ b/osu.Game/Online/Matchmaking/MatchmakingPool.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics.CodeAnalysis; +using MessagePack; + +namespace osu.Game.Online.Matchmaking +{ + [MessagePackObject] + [Serializable] + public class MatchmakingPool : IEquatable + { + [Key(0)] + public int Id { get; set; } + + [Key(1)] + public int RulesetId { get; set; } + + [Key(2)] + public int Variant { get; set; } + + [Key(3)] + public string Name { get; set; } = string.Empty; + + public bool Equals(MatchmakingPool? other) + => other != null + && Id == other.Id + && RulesetId == other.RulesetId + && Variant == other.Variant + && Name == other.Name; + + public override bool Equals(object? obj) + => obj is MatchmakingPool other && Equals(other); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() => HashCode.Combine(Id, RulesetId, Variant, Name); + } +} diff --git a/osu.Game/Online/Matchmaking/MatchmakingQueueStatus.cs b/osu.Game/Online/Matchmaking/MatchmakingQueueStatus.cs new file mode 100644 index 0000000000..a57e04bd10 --- /dev/null +++ b/osu.Game/Online/Matchmaking/MatchmakingQueueStatus.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 MessagePack; + +namespace osu.Game.Online.Matchmaking +{ + [Serializable] + [MessagePackObject] + [Union(0, typeof(Searching))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + [Union(1, typeof(MatchFound))] + [Union(2, typeof(JoiningMatch))] + public abstract class MatchmakingQueueStatus + { + [Serializable] + [MessagePackObject] + public class Searching : MatchmakingQueueStatus + { + } + + [Serializable] + [MessagePackObject] + public class MatchFound : MatchmakingQueueStatus + { + } + + [Serializable] + [MessagePackObject] + public class JoiningMatch : MatchmakingQueueStatus + { + } + } +} diff --git a/osu.Game/Online/Matchmaking/MatchmakingStageCountdown.cs b/osu.Game/Online/Matchmaking/MatchmakingStageCountdown.cs new file mode 100644 index 0000000000..8df1bb000a --- /dev/null +++ b/osu.Game/Online/Matchmaking/MatchmakingStageCountdown.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 MessagePack; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; + +namespace osu.Game.Online.Matchmaking +{ + [MessagePackObject] + public class MatchmakingStageCountdown : MultiplayerCountdown + { + [Key(2)] + public MatchmakingStage Stage { get; set; } + } +} diff --git a/osu.Game/Online/Metadata/IMetadataClient.cs b/osu.Game/Online/Metadata/IMetadataClient.cs index 97c1bbde5f..a4251fae80 100644 --- a/osu.Game/Online/Metadata/IMetadataClient.cs +++ b/osu.Game/Online/Metadata/IMetadataClient.cs @@ -21,6 +21,11 @@ namespace osu.Game.Online.Metadata /// Task UserPresenceUpdated(int userId, UserPresence? status); + /// + /// Delivers and update of the of a friend with the supplied . + /// + Task FriendPresenceUpdated(int userId, UserPresence? presence); + /// /// Delivers an update of the current "daily challenge" status. /// Null value means there is no "daily challenge" currently active. diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index 8a5fe1733e..0679191a52 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -3,9 +3,13 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Users; namespace osu.Game.Online.Metadata @@ -14,6 +18,9 @@ namespace osu.Game.Online.Metadata { public abstract IBindable IsConnected { get; } + [Resolved] + private IAPIProvider api { get; set; } = null!; + #region Beatmap metadata updates public abstract Task GetChangesSince(int queueId); @@ -33,37 +40,108 @@ namespace osu.Game.Online.Metadata #region User presence updates /// - /// Whether the client is currently receiving user presence updates from the server. + /// The information about the current user. /// - public abstract IBindable IsWatchingUserPresence { get; } + public abstract UserPresence LocalUserPresence { get; } /// /// Dictionary keyed by user ID containing all of the information about currently online users received from the server. /// - public abstract IBindableDictionary UserStates { get; } + public abstract IBindableDictionary UserPresences { get; } + + /// + /// Dictionary keyed by user ID containing all of the information about currently online friends received from the server. + /// + public abstract IBindableDictionary FriendPresences { get; } + + /// + /// Attempts to retrieve the presence of a user. + /// + /// + /// This will return data if the client is currently receiving presence data. See . + /// + /// The user ID. + /// The user presence, or null if not available or the user's offline. + public UserPresence? GetPresence(int userId) + { + if (userId == api.LocalUser.Value.OnlineID) + return LocalUserPresence; + + if (FriendPresences.TryGetValue(userId, out UserPresence presence)) + return presence; + + if (UserPresences.TryGetValue(userId, out presence)) + return presence; + + return null; + } - /// public abstract Task UpdateActivity(UserActivity? activity); - /// public abstract Task UpdateStatus(UserStatus? status); - /// - public abstract Task BeginWatchingUserPresence(); + private int userPresenceWatchCount; - /// - public abstract Task EndWatchingUserPresence(); + protected bool IsWatchingUserPresence + => Interlocked.CompareExchange(ref userPresenceWatchCount, userPresenceWatchCount, userPresenceWatchCount) > 0; + + /// + /// Signals to the server that we want to begin receiving status updates for all users. + /// + /// An which will end the session when disposed. + public IDisposable BeginWatchingUserPresence() => new UserPresenceWatchToken(this); + + Task IMetadataServer.BeginWatchingUserPresence() + { + if (Interlocked.Increment(ref userPresenceWatchCount) == 1) + return BeginWatchingUserPresenceInternal(); + + return Task.CompletedTask; + } + + Task IMetadataServer.EndWatchingUserPresence() + { + if (Interlocked.Decrement(ref userPresenceWatchCount) == 0) + return EndWatchingUserPresenceInternal(); + + return Task.CompletedTask; + } + + protected abstract Task BeginWatchingUserPresenceInternal(); + + protected abstract Task EndWatchingUserPresenceInternal(); - /// public abstract Task UserPresenceUpdated(int userId, UserPresence? presence); + public abstract Task FriendPresenceUpdated(int userId, UserPresence? presence); + + private class UserPresenceWatchToken : IDisposable + { + private readonly IMetadataServer server; + private bool isDisposed; + + public UserPresenceWatchToken(IMetadataServer server) + { + this.server = server; + server.BeginWatchingUserPresence().FireAndForget(); + } + + public void Dispose() + { + if (isDisposed) + return; + + server.EndWatchingUserPresence().FireAndForget(); + isDisposed = true; + } + } + #endregion #region Daily Challenge public abstract IBindable DailyChallengeInfo { get; } - /// public abstract Task DailyChallengeUpdated(DailyChallengeInfo? info); #endregion diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index a3041c6753..75b0187388 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -12,6 +12,7 @@ using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; using osu.Game.Users; namespace osu.Game.Online.Metadata @@ -20,38 +21,42 @@ namespace osu.Game.Online.Metadata { public override IBindable IsConnected { get; } = new Bindable(); - public override IBindable IsWatchingUserPresence => isWatchingUserPresence; - private readonly BindableBool isWatchingUserPresence = new BindableBool(); + public override UserPresence LocalUserPresence => localUserPresence; + private UserPresence localUserPresence; - public override IBindableDictionary UserStates => userStates; - private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary UserPresences => userPresences; + private readonly BindableDictionary userPresences = new BindableDictionary(); + + public override IBindableDictionary FriendPresences => friendPresences; + private readonly BindableDictionary friendPresences = new BindableDictionary(); public override IBindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); private readonly string endpoint; + [Resolved] + private IAPIProvider api { get; set; } = null!; + private IHubClientConnector? connector; - private Bindable lastQueueId = null!; - private IBindable localUser = null!; + private IBindable userStatus = null!; private IBindable userActivity = null!; - private IBindable? userStatus; private HubConnection? connection => connector?.CurrentConnection; public OnlineMetadataClient(EndpointConfiguration endpoints) { - endpoint = endpoints.MetadataEndpointUrl; + endpoint = endpoints.MetadataUrl; } [BackgroundDependencyLoader] - private void load(IAPIProvider api, OsuConfigManager config) + private void load(OsuConfigManager config, SessionStatics session) { // 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(OnlineMetadataClient), endpoint, false); + connector = api.GetHubConnector(nameof(OnlineMetadataClient), endpoint); if (connector != null) { @@ -61,6 +66,7 @@ namespace osu.Game.Online.Metadata // https://github.com/dotnet/aspnetcore/issues/15198 connection.On(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated); connection.On(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated); + connection.On(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated); connection.On(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated); connection.On(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMetadataClient)this).DisconnectRequested); @@ -70,29 +76,26 @@ namespace osu.Game.Online.Metadata IsConnected.BindValueChanged(isConnectedChanged, true); } - lastQueueId = config.GetBindable(OsuSetting.LastProcessedMetadataId); - localUser = api.LocalUser.GetBoundCopy(); - userActivity = api.Activity.GetBoundCopy()!; + lastQueueId = config.GetBindable(OsuSetting.LastProcessedMetadataId); + userStatus = config.GetBindable(OsuSetting.UserOnlineStatus); + userActivity = session.GetBindable(Static.UserOnlineActivity); } protected override void LoadComplete() { base.LoadComplete(); - localUser.BindValueChanged(_ => + + userStatus.BindValueChanged(status => { if (localUser.Value is not GuestUser) - { - userStatus = localUser.Value.Status.GetBoundCopy(); - userStatus.BindValueChanged(status => UpdateStatus(status.NewValue), true); - } - else - userStatus = null; + UpdateStatus(status.NewValue).FireAndForget(); }, true); + userActivity.BindValueChanged(activity => { if (localUser.Value is not GuestUser) - UpdateActivity(activity.NewValue); + UpdateActivity(activity.NewValue).FireAndForget(); }, true); } @@ -104,17 +107,22 @@ namespace osu.Game.Online.Metadata { Schedule(() => { - isWatchingUserPresence.Value = false; - userStates.Clear(); + userPresences.Clear(); + friendPresences.Clear(); dailyChallengeInfo.Value = null; + localUserPresence = default; }); + return; } + if (IsWatchingUserPresence) + BeginWatchingUserPresenceInternal().FireAndForget(); + if (localUser.Value is not GuestUser) { - UpdateActivity(userActivity.Value); - UpdateStatus(userStatus?.Value); + UpdateActivity(userActivity.Value).FireAndForget(); + UpdateStatus(userStatus.Value).FireAndForget(); } if (lastQueueId.Value >= 0) @@ -194,47 +202,65 @@ namespace osu.Game.Online.Metadata return connection.InvokeAsync(nameof(IMetadataServer.UpdateStatus), status); } + protected override Task BeginWatchingUserPresenceInternal() + { + if (connector?.IsConnected.Value != true) + return Task.CompletedTask; + + Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network); + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)); + } + + protected override Task EndWatchingUserPresenceInternal() + { + if (connector?.IsConnected.Value != true) + return Task.CompletedTask; + + Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); + + // must be scheduled before any remote calls to avoid mis-ordering. + Schedule(() => userPresences.Clear()); + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)); + } + public override Task UserPresenceUpdated(int userId, UserPresence? presence) { Schedule(() => { if (presence?.Status != null) - userStates[userId] = presence.Value; + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = presence.Value; + else + userPresences[userId] = presence.Value; + } else - userStates.Remove(userId); + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = default; + else + userPresences.Remove(userId); + } }); return Task.CompletedTask; } - public override async Task BeginWatchingUserPresence() + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) { - if (connector?.IsConnected.Value != true) - throw new OperationCanceledException(); - - Debug.Assert(connection != null); - await connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)).ConfigureAwait(false); - Schedule(() => isWatchingUserPresence.Value = true); - Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network); - } - - public override async Task EndWatchingUserPresence() - { - try + Schedule(() => { - if (connector?.IsConnected.Value != true) - throw new OperationCanceledException(); + if (presence?.Status != null) + friendPresences[userId] = presence.Value; + else + friendPresences.Remove(userId); + }); - // must be scheduled before any remote calls to avoid mis-ordering. - Schedule(() => userStates.Clear()); - Debug.Assert(connection != null); - await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); - Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); - } - finally - { - Schedule(() => isWatchingUserPresence.Value = false); - } + return Task.CompletedTask; } public override Task DailyChallengeUpdated(DailyChallengeInfo? info) diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index 0452d8b79c..c91128401d 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -95,6 +95,14 @@ namespace osu.Game.Online.Multiplayer /// The new beatmap availability state of the user. Task UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability); + /// + /// Signals that a user in this room changed their style. + /// + /// The ID of the user whose style changed. + /// The user's beatmap. + /// The user's ruleset. + Task UserStyleChanged(int userId, int? beatmapId, int? rulesetId); + /// /// Signals that a user in this room changed their local mods. /// @@ -141,5 +149,15 @@ namespace osu.Game.Online.Multiplayer /// /// The changed item. Task PlaylistItemChanged(MultiplayerPlaylistItem item); + + /// + /// Signals that a user has requested to skip the beatmap intro. + /// + Task UserVotedToSkipIntro(int userId); + + /// + /// Signals that the vote to skip the beatmap intro has passed. + /// + Task VoteToSkipIntroPassed(); } } diff --git a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs index f266c38b8b..0ee9fa54cd 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs @@ -10,6 +10,13 @@ namespace osu.Game.Online.Multiplayer /// public interface IMultiplayerLoungeServer { + /// + /// Request to create a multiplayer room. + /// + /// The room to create. + /// The created multiplayer room. + Task CreateRoom(MultiplayerRoom room); + /// /// Request to join a multiplayer room. /// diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 55f00b447f..169d5d1b83 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -57,6 +57,13 @@ namespace osu.Game.Online.Multiplayer /// The proposed new beatmap availability state. Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + /// + /// Change the local user's style in the currently joined room. + /// + /// The beatmap. + /// The ruleset. + Task ChangeUserStyle(int? beatmapId, int? rulesetId); + /// /// Change the local user's mods in the currently joined room. /// @@ -105,6 +112,11 @@ namespace osu.Game.Online.Multiplayer /// The item to remove. Task RemovePlaylistItem(long playlistItemId); + /// + /// Votes to skip the beatmap intro. + /// + Task VoteToSkipIntro(); + /// /// Invites a player to the current room. /// diff --git a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs index b76a1cc05d..860fb90258 100644 --- a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs +++ b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs @@ -9,5 +9,9 @@ namespace osu.Game.Online.Multiplayer [Serializable] public class InvalidPasswordException : HubException { + public InvalidPasswordException() + : base("Invalid password") + { + } } } diff --git a/osu.Game/Online/Multiplayer/MatchRoomState.cs b/osu.Game/Online/Multiplayer/MatchRoomState.cs index cae3aaf7d0..25de8c7fab 100644 --- a/osu.Game/Online/Multiplayer/MatchRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchRoomState.cs @@ -3,6 +3,7 @@ using System; using MessagePack; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; namespace osu.Game.Online.Multiplayer @@ -14,6 +15,7 @@ namespace osu.Game.Online.Multiplayer [Serializable] [MessagePackObject] [Union(0, typeof(TeamVersusRoomState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + [Union(1, typeof(MatchmakingRoomState))] public abstract class MatchRoomState { } diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs index 376ff4d261..529a299438 100644 --- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs @@ -3,6 +3,7 @@ using System; using MessagePack; +using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer.Countdown; namespace osu.Game.Online.Multiplayer @@ -15,6 +16,7 @@ namespace osu.Game.Online.Multiplayer // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. [Union(0, typeof(CountdownStartedEvent))] [Union(1, typeof(CountdownStoppedEvent))] + [Union(2, typeof(MatchmakingAvatarActionEvent))] public abstract class MatchServerEvent { } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs new file mode 100644 index 0000000000..b55fa63844 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs @@ -0,0 +1,101 @@ +// Copyright (c) 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 MessagePack; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking +{ + /// + /// Describes the state of a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingRoomState : MatchRoomState + { + /// + /// The current room status. + /// + [Key(0)] + public MatchmakingStage Stage { get; set; } + + /// + /// The current round number (1-based). + /// + [Key(1)] + public int CurrentRound { get; set; } + + /// + /// The playlist items that were picked as gameplay candidates. + /// + [Key(2)] + public long[] CandidateItems { get; set; } = []; + + /// + /// The final gameplay candidate. + /// + [Key(3)] + public long CandidateItem { get; set; } + + /// + /// The users in the room. + /// + [Key(4)] + public MatchmakingUserList Users { get; set; } = new MatchmakingUserList(); + + /// + /// Advances to the next round. + /// + public void AdvanceRound() + { + CurrentRound++; + } + + /// + /// Sets scores for the current round, applying points and adjusting user placements. + /// + /// + /// When applying points: + /// + /// Matching scores are considered to be placed in the lower-equal (e.g. two equal top scores are considered "equal-second"). + /// Failed scores are considered to have passed the map. + /// Missing scores are not considered. + /// + /// + /// The scores to apply. + /// The number of points to award for each placement position (0-indexed). Must be at least of equal length to . + public void RecordScores(SoloScoreInfo[] scores, int[] placementPoints) + { + if (placementPoints.Length < scores.Length) + throw new ArgumentException($"{nameof(placementPoints)} must be at least of equal length to {nameof(scores)}."); + + SoloScoreInfo[] orderedScores = scores.OrderByDescending(s => s.TotalScore).ToArray(); + + int placement = 0; + + foreach (var scoreGroup in orderedScores.GroupBy(s => s.TotalScore)) + { + placement += scoreGroup.Count(); + + foreach (var score in scoreGroup) + { + MatchmakingUser mmUser = Users.GetOrAdd(score.UserID); + mmUser.Points += placementPoints[placement - 1]; + + MatchmakingRound mmRound = mmUser.Rounds.GetOrAdd(CurrentRound); + mmRound.Placement = placement; + mmRound.TotalScore = score.TotalScore; + mmRound.Accuracy = score.Accuracy; + mmRound.MaxCombo = score.MaxCombo; + mmRound.Statistics = score.Statistics; + } + } + + int i = 1; + foreach (var user in Users.Order(new MatchmakingUserComparer(CurrentRound))) + user.Placement = i++; + } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRound.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRound.cs new file mode 100644 index 0000000000..6a9d595ab5 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRound.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 System.Collections.Generic; +using MessagePack; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking +{ + /// + /// Describes a user's score for a round of a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingRound + { + /// + /// The round. + /// + [Key(0)] + public required int Round { get; set; } + + /// + /// The user's placement in this round (1-based). + /// + [Key(1)] + public int Placement { get; set; } + + /// + /// The achieved total score. + /// + [Key(2)] + public long TotalScore { get; set; } + + /// + /// The achieved accuracy. + /// + [Key(3)] + public double Accuracy { get; set; } + + /// + /// The achieved maximum combo. + /// + [Key(4)] + public int MaxCombo { get; set; } + + /// + /// The achieved score statistics. + /// + [Key(5)] + public IDictionary Statistics { get; set; } = new Dictionary(); + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs new file mode 100644 index 0000000000..fb9a713c10 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections; +using System.Collections.Generic; +using MessagePack; + +namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking +{ + /// + /// Describes the per-round scores of a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingRoundList : IEnumerable + { + /// + /// A key-value-pair mapping of rounds to scores. + /// + [Key(0)] + public IDictionary RoundsDictionary { get; set; } = new Dictionary(); + + /// + /// The total number of rounds. + /// + [IgnoreMember] + public int Count => RoundsDictionary.Count; + + /// + /// Retrieves or adds a entry to this list. + /// + /// The round. + public MatchmakingRound GetOrAdd(int round) + { + if (RoundsDictionary.TryGetValue(round, out MatchmakingRound? score)) + return score; + + return RoundsDictionary[round] = new MatchmakingRound { Round = round }; + } + + public IEnumerator GetEnumerator() => RoundsDictionary.Values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.cs new file mode 100644 index 0000000000..edffa4ec23 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingStage.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; + +namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking +{ + /// + /// Describes the current status of a matchmaking room. + /// + [Serializable] + public enum MatchmakingStage + { + /// + /// The initial state of a room. Users are still joining. + /// + WaitingForClientsJoin, + + /// + /// A short delay before the round begins. + /// + RoundWarmupTime, + + /// + /// Users are given a chance to lock in their beatmap picks. + /// + UserBeatmapSelect, + + /// + /// Clients have sent their picks, and the server has responded with the finalised beatmap. + /// + ServerBeatmapFinalised, + + /// + /// Clients are given an opportunity to download the beatmap. + /// + WaitingForClientsBeatmapDownload, + + /// + /// A short delay before gameplay starts. + /// + GameplayWarmupTime, + + /// + /// Gameplay is ongoing. + /// + Gameplay, + + /// + /// Gameplay has finished, results are being displayed. + /// + ResultsDisplaying, + + /// + /// All rounds have completed. Users may still be chatting. + /// + Ended + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs new file mode 100644 index 0000000000..ac97b114d8 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; + +namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking +{ + /// + /// Describes a user of a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingUser + { + /// + /// The user's ID. + /// + [Key(0)] + public required int UserId { get; set; } + + /// + /// The aggregate room placement (1-based). + /// + [Key(1)] + public int? Placement { get; set; } + + /// + /// The aggregate points. + /// + [Key(2)] + public int Points { get; set; } + + /// + /// The scores set. + /// + [Key(3)] + public MatchmakingRoundList Rounds { get; set; } = new MatchmakingRoundList(); + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserComparer.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserComparer.cs new file mode 100644 index 0000000000..74da6a9b2a --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserComparer.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; + +namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking +{ + /// + /// Orders in order of placement. + /// + public class MatchmakingUserComparer : Comparer + { + private readonly int rounds; + + public MatchmakingUserComparer(int rounds) + { + this.rounds = rounds; + } + + public override int Compare(MatchmakingUser? x, MatchmakingUser? y) + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + + // X appears earlier in the list if it has more points. + if (x.Points > y.Points) + return -1; + + // Y appears earlier in the list if it has more points. + if (y.Points > x.Points) + return 1; + + // Tiebreaker 1 (likely): From each user's point-of-view, their earliest and best placement. + for (int r = 1; r <= rounds; r++) + { + MatchmakingRound? xRound; + x.Rounds.RoundsDictionary.TryGetValue(r, out xRound); + + MatchmakingRound? yRound; + y.Rounds.RoundsDictionary.TryGetValue(r, out yRound); + + // Nothing to do if both players haven't played this round. + if (xRound == null && yRound == null) + continue; + + // X appears later in the list if it hasn't played this round. + if (xRound == null) + return 1; + + // Y appears later in the list if it hasn't played this round. + if (yRound == null) + return -1; + + // X appears earlier in the list if it has a better placement in the round. + int compare = xRound.Placement.CompareTo(yRound.Placement); + if (compare != 0) + return compare; + } + + // Tiebreaker 2 (unlikely): User ID. + return x.UserId.CompareTo(y.UserId); + } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs new file mode 100644 index 0000000000..23a246db5d --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections; +using System.Collections.Generic; +using MessagePack; + +namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking +{ + /// + /// Describes the users of a matchmaking room. + /// + [Serializable] + [MessagePackObject] + public class MatchmakingUserList : IEnumerable + { + /// + /// A key-value-pair mapping of ids to users. + /// + [Key(0)] + public IDictionary UserDictionary { get; set; } = new Dictionary(); + + /// + /// The total number of users. + /// + [IgnoreMember] + public int Count => UserDictionary.Count; + + /// + /// Retrieves or adds a entry to this list. + /// + /// The user ID. + public MatchmakingUser GetOrAdd(int userId) + { + if (UserDictionary.TryGetValue(userId, out MatchmakingUser? user)) + return user; + + return UserDictionary[userId] = new MatchmakingUser { UserId = userId }; + } + + public IEnumerator GetEnumerator() => UserDictionary.Values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs index ac3b9724cc..bf11713663 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs @@ -5,6 +5,7 @@ using MessagePack; namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { + [MessagePackObject] public class TeamVersusUserState : MatchUserState { [Key(0)] diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs index 8515256581..02704ea161 100644 --- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs @@ -3,6 +3,7 @@ using System; using MessagePack; +using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; @@ -17,6 +18,7 @@ namespace osu.Game.Online.Multiplayer [Union(0, typeof(ChangeTeamRequest))] [Union(1, typeof(StartMatchCountdownRequest))] [Union(2, typeof(StopCountdownRequest))] + [Union(3, typeof(MatchmakingAvatarActionRequest))] public abstract class MatchUserRequest { } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 998a34931d..8a41c11ae6 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -11,14 +11,15 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Game.Database; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -26,7 +27,7 @@ using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { - public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer + public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer, IMatchmakingServer, IMatchmakingClient { public Action? PostNotification { protected get; set; } @@ -37,6 +38,21 @@ namespace osu.Game.Online.Multiplayer /// public virtual event Action? RoomUpdated; + /// + /// Invoked when a user's local style is changed. + /// + public event Action? UserStyleChanged; + + /// + /// Invoked when a user's local mods are changed. + /// + public event Action? UserModsChanged; + + /// + /// Invoked when the room's settings are changed. + /// + public event Action? SettingsChanged; + /// /// Invoked when a new user joins the room. /// @@ -52,6 +68,11 @@ namespace osu.Game.Online.Multiplayer /// public event Action? UserKicked; + /// + /// Invoked when the room's host is changed. + /// + public event Action? HostChanged; + /// /// Invoked when a new item is added to the playlist. /// @@ -92,6 +113,29 @@ namespace osu.Game.Online.Multiplayer /// public event Action? Disconnecting; + public event Action? CountdownStarted; + + public event Action? CountdownStopped; + + public event Action? MatchEvent; + + public event Action? UserStateChanged; + + public event Action? MatchmakingQueueJoined; + public event Action? MatchmakingQueueLeft; + public event Action? MatchmakingRoomInvited; + public event Action? MatchmakingRoomReady; + public event Action? MatchmakingLobbyStatusChanged; + public event Action? MatchmakingQueueStatusChanged; + public event Action? MatchmakingItemSelected; + public event Action? MatchmakingItemDeselected; + public event Action? MatchRoomStateChanged; + + public event Action? UserVotedToSkipIntro; + public event Action? VoteToSkipIntroPassed; + + public event Action? BeatmapAvailabilityChanged; + /// /// Whether the is currently connected. /// This is NOT thread safe and usage should be scheduled. @@ -152,14 +196,20 @@ namespace osu.Game.Online.Multiplayer protected Room? APIRoom { get; private set; } + private readonly Queue pendingRequests = new Queue(); + [BackgroundDependencyLoader] private void load() { IsConnected.BindValueChanged(connected => Scheduler.Add(() => { - // clean up local room state on server disconnect. - if (!connected.NewValue && Room != null) - LeaveRoom(); + if (!connected.NewValue) + { + if (Room != null) + LeaveRoom().FireAndForget(); + + MatchmakingQueueLeft?.Invoke(); + } })); } @@ -167,60 +217,99 @@ namespace osu.Game.Online.Multiplayer private CancellationTokenSource? joinCancellationSource; /// - /// Joins the for a given API . + /// Creates and joins a described by an API . /// - /// The API . - /// An optional password to use for the join operation. - public async Task JoinRoom(Room room, string? password = null) + /// The API describing the room to create. + /// If the current user is already in another room. + public async Task CreateRoom(Room room) { if (Room != null) - throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + throw new InvalidOperationException("Cannot create a multiplayer room while already in one."); var cancellationSource = joinCancellationSource = new CancellationTokenSource(); await joinOrLeaveTaskChain.Add(async () => { - Debug.Assert(room.RoomID != null); - - // Join the server-side room. - var joinedRoom = await JoinRoom(room.RoomID.Value, password ?? room.Password).ConfigureAwait(false); - Debug.Assert(joinedRoom != null); - - // Populate users. - Debug.Assert(joinedRoom.Users != null); - await PopulateUsers(joinedRoom.Users).ConfigureAwait(false); - - // Update the stored room (must be done on update thread for thread-safety). - await runOnUpdateThreadAsync(() => - { - Debug.Assert(Room == null); - - Room = joinedRoom; - APIRoom = room; - - Debug.Assert(joinedRoom.Playlist.Count > 0); - - APIRoom.Playlist = joinedRoom.Playlist.Select(item => new PlaylistItem(item)).ToArray(); - APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); - - // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. - APIRoom.EndDate = null; - - Debug.Assert(LocalUser != null); - addUserToAPIRoom(LocalUser); - - foreach (var user in joinedRoom.Users) - updateUserPlayingState(user.UserID, user.State); - - updateLocalRoomSettings(joinedRoom.Settings); - - postServerShuttingDownNotification(); - - OnRoomJoined(); - }, cancellationSource.Token).ConfigureAwait(false); + await runOnUpdateThreadAsync(() => pendingRequests.Clear(), cancellationSource.Token).ConfigureAwait(false); + var multiplayerRoom = await CreateRoomInternal(new MultiplayerRoom(room)).ConfigureAwait(false); + await setupJoinedRoom(room, multiplayerRoom, cancellationSource.Token).ConfigureAwait(false); }, cancellationSource.Token).ConfigureAwait(false); } + /// + /// Joins the for a given API . + /// + /// The API . + /// An optional password to use for the join operation. + /// If the current user is already in another room, or does not represent an active room. + public async Task JoinRoom(Room room, string? password = null) + { + if (Room != null) + throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + + if (room.RoomID == null) + throw new InvalidOperationException("Cannot join an inactive room."); + + var cancellationSource = joinCancellationSource = new CancellationTokenSource(); + + await joinOrLeaveTaskChain.Add(async () => + { + await runOnUpdateThreadAsync(() => pendingRequests.Clear(), cancellationSource.Token).ConfigureAwait(false); + var multiplayerRoom = await JoinRoomInternal(room.RoomID.Value, password ?? room.Password).ConfigureAwait(false); + await setupJoinedRoom(room, multiplayerRoom, cancellationSource.Token).ConfigureAwait(false); + }, cancellationSource.Token).ConfigureAwait(false); + } + + /// + /// Performs post-join setup of a . + /// + /// The incoming API that was requested to be joined. + /// The resuling that was joined. + /// A token to cancel the process. + private async Task setupJoinedRoom(Room apiRoom, MultiplayerRoom joinedRoom, CancellationToken cancellationToken) + { + // Populate users. + await PopulateUsers(joinedRoom.Users).ConfigureAwait(false); + if (joinedRoom.Host != null) + await PopulateUsers([joinedRoom.Host]).ConfigureAwait(false); + + // Update the stored room (must be done on update thread for thread-safety). + await runOnUpdateThreadAsync(() => + { + Debug.Assert(Room == null); + Debug.Assert(APIRoom == null); + + Room = joinedRoom; + APIRoom = apiRoom; + + while (pendingRequests.TryDequeue(out Action? action)) + action(); + + APIRoom.RoomID = joinedRoom.RoomID; + APIRoom.ChannelId = joinedRoom.ChannelID; + APIRoom.Host = joinedRoom.Host?.User; + APIRoom.Playlist = joinedRoom.Playlist.Select(item => new PlaylistItem(item)).ToArray(); + APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); + // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. + APIRoom.EndDate = null; + + Debug.Assert(LocalUser != null); + addUserToAPIRoom(LocalUser); + + foreach (var user in joinedRoom.Users) + updateUserPlayingState(user.UserID, user.State); + + updateLocalRoomSettings(joinedRoom.Settings); + + while (pendingRequests.TryDequeue(out Action? action)) + action(); + + postServerShuttingDownNotification(); + + OnRoomJoined(); + }, cancellationToken).ConfigureAwait(false); + } + /// /// Fired when the room join sequence is complete /// @@ -228,16 +317,11 @@ namespace osu.Game.Online.Multiplayer { } - /// - /// Joins the with a given ID. - /// - /// The room ID. - /// An optional password to use when joining the room. - /// The joined . - protected abstract Task JoinRoom(long roomId, string? password = null); - public Task LeaveRoom() { + if (Room == null) + return Task.CompletedTask; + // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. // This includes the setting of Room itself along with the initial update of the room settings on join. joinCancellationSource?.Cancel(); @@ -254,13 +338,44 @@ namespace osu.Game.Online.Multiplayer RoomUpdated?.Invoke(); }); - return joinOrLeaveTaskChain.Add(async () => + return Task.Run(async () => { - await scheduledReset.ConfigureAwait(false); - await LeaveRoomInternal().ConfigureAwait(false); + try + { + await joinOrLeaveTaskChain.Add(async () => + { + await scheduledReset.ConfigureAwait(false); + await LeaveRoomInternal().ConfigureAwait(false); + }).ConfigureAwait(false); + } + finally + { + await runOnUpdateThreadAsync(() => + { + pendingRequests.Clear(); + }).ConfigureAwait(false); + } }); } + /// + /// Creates the with the given settings. + /// + /// The room. + /// The joined + protected abstract Task CreateRoomInternal(MultiplayerRoom room); + + /// + /// Joins the with a given ID. + /// + /// The room ID. + /// An optional password to use when joining the room. + /// The joined . + protected abstract Task JoinRoomInternal(long roomId, string? password = null); + + /// + /// Leaves the currently-joined . + /// protected abstract Task LeaveRoomInternal(); public abstract Task InvitePlayer(int userId); @@ -359,6 +474,8 @@ namespace osu.Game.Online.Multiplayer public abstract Task DisconnectInternal(); + public abstract Task ChangeUserStyle(int? beatmapId, int? rulesetId); + /// /// Change the local user's mods in the currently joined room. /// @@ -381,13 +498,13 @@ namespace osu.Game.Online.Multiplayer public abstract Task RemovePlaylistItem(long playlistItemId); + public abstract Task VoteToSkipIntro(); + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); Debug.Assert(APIRoom != null); Room.State = state; @@ -395,20 +512,22 @@ namespace osu.Game.Online.Multiplayer switch (state) { case MultiplayerRoomState.Open: - APIRoom.Status = APIRoom.HasPassword ? new RoomStatusOpenPrivate() : new RoomStatusOpen(); + APIRoom.Status = RoomStatus.Idle; break; + case MultiplayerRoomState.WaitingForLoad: case MultiplayerRoomState.Playing: - APIRoom.Status = new RoomStatusPlaying(); + APIRoom.Status = RoomStatus.Playing; break; case MultiplayerRoomState.Closed: - APIRoom.Status = new RoomStatusEnded(); + APIRoom.EndDate = DateTimeOffset.Now; + APIRoom.Status = RoomStatus.Idle; break; } RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } @@ -417,10 +536,9 @@ namespace osu.Game.Online.Multiplayer { await PopulateUsers([user]).ConfigureAwait(false); - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; + Debug.Assert(Room != null); // for sanity, ensure that there can be no duplicate users in the room user list. if (Room.Users.Any(existing => existing.UserID == user.UserID)) @@ -432,21 +550,45 @@ namespace osu.Game.Online.Multiplayer UserJoined?.Invoke(user); RoomUpdated?.Invoke(); - }, false); + }); } - Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) => - handleUserLeft(user, UserLeft); + Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) + { + handleRoomRequest(() => handleUserLeft(user, UserLeft)); + return Task.CompletedTask; + } Task IMultiplayerClient.UserKicked(MultiplayerRoomUser user) { - if (LocalUser == null) - return Task.CompletedTask; + handleRoomRequest(() => + { + if (LocalUser == null) + return; - if (user.Equals(LocalUser)) - LeaveRoom(); + if (user.Equals(LocalUser)) + LeaveRoom().FireAndForget(); - return handleUserLeft(user, UserKicked); + handleUserLeft(user, UserKicked); + }); + + return Task.CompletedTask; + } + + private void handleUserLeft(MultiplayerRoomUser user, Action? callback) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + Debug.Assert(Room != null); + + Room.Users.Remove(user); + PlayingUserIds.Remove(user.UserID); + + Debug.Assert(APIRoom != null); + APIRoom.RecentParticipants = APIRoom.RecentParticipants.Where(u => u.Id != user.UserID).ToArray(); + APIRoom.ParticipantCount--; + + callback?.Invoke(user); + RoomUpdated?.Invoke(); } async Task IMultiplayerClient.Invited(int invitedBy, long roomID, string password) @@ -456,16 +598,14 @@ namespace osu.Game.Online.Multiplayer if (apiUser == null || apiRoom == null) return; - PostNotification?.Invoke( - new UserAvatarNotification(apiUser, NotificationsStrings.InvitedYouToTheMultiplayer(apiUser.Username, apiRoom.Name)) + PostNotification?.Invoke(new MultiplayerInvitationNotification(apiUser, apiRoom) + { + Activated = () => { - Activated = () => - { - PresentMatch?.Invoke(apiRoom, password); - return true; - } + PresentMatch?.Invoke(apiRoom, password); + return true; } - ); + }); Task getRoomAsync(long id) { @@ -493,34 +633,11 @@ namespace osu.Game.Online.Multiplayer APIRoom.ParticipantCount++; } - private Task handleUserLeft(MultiplayerRoomUser user, Action? callback) - { - Scheduler.Add(() => - { - if (Room == null) - return; - - Room.Users.Remove(user); - PlayingUserIds.Remove(user.UserID); - - Debug.Assert(APIRoom != null); - APIRoom.RecentParticipants = APIRoom.RecentParticipants.Where(u => u.Id != user.UserID).ToArray(); - APIRoom.ParticipantCount--; - - callback?.Invoke(user); - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - Task IMultiplayerClient.HostChanged(int userId) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); Debug.Assert(APIRoom != null); var user = Room.Users.FirstOrDefault(u => u.UserID == userId); @@ -528,23 +645,26 @@ namespace osu.Game.Online.Multiplayer Room.Host = user; APIRoom.Host = user?.User; + HostChanged?.Invoke(user); RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { - Scheduler.Add(() => updateLocalRoomSettings(newSettings)); + handleRoomRequest(() => updateLocalRoomSettings(newSettings)); return Task.CompletedTask; } Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) { - Scheduler.Add(() => + handleRoomRequest(() => { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); // TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713. if (user == null) @@ -553,17 +673,20 @@ namespace osu.Game.Online.Multiplayer user.State = state; updateUserPlayingState(userId, state); + UserStateChanged?.Invoke(user, state); RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } Task IMultiplayerClient.MatchUserStateChanged(int userId, MatchUserState state) { - Scheduler.Add(() => + handleRoomRequest(() => { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); // TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713. if (user == null) @@ -571,36 +694,36 @@ namespace osu.Game.Online.Multiplayer user.MatchState = state; RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } Task IMultiplayerClient.MatchRoomStateChanged(MatchRoomState state) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; + Debug.Assert(Room != null); Room.MatchState = state; + MatchRoomStateChanged?.Invoke(state); RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } - public Task MatchEvent(MatchServerEvent e) + Task IMultiplayerClient.MatchEvent(MatchServerEvent e) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; + Debug.Assert(Room != null); switch (e) { case CountdownStartedEvent countdownStartedEvent: Room.ActiveCountdowns.Add(countdownStartedEvent.Countdown); + CountdownStarted?.Invoke(countdownStartedEvent.Countdown); switch (countdownStartedEvent.Countdown) { @@ -613,13 +736,19 @@ namespace osu.Game.Online.Multiplayer case CountdownStoppedEvent countdownStoppedEvent: MultiplayerCountdown? countdown = Room.ActiveCountdowns.FirstOrDefault(countdown => countdown.ID == countdownStoppedEvent.ID); + if (countdown != null) + { Room.ActiveCountdowns.Remove(countdown); + CountdownStopped?.Invoke(countdown); + } + break; } + MatchEvent?.Invoke(e); RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } @@ -636,9 +765,11 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { - Scheduler.Add(() => + handleRoomRequest(() => { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); // errors here are not critical - beatmap availability state is mostly for display. if (user == null) @@ -646,17 +777,42 @@ namespace osu.Game.Online.Multiplayer user.BeatmapAvailability = beatmapAvailability; + BeatmapAvailabilityChanged?.Invoke(user, beatmapAvailability); RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } - public Task UserModsChanged(int userId, IEnumerable mods) + Task IMultiplayerClient.UserStyleChanged(int userId, int? beatmapId, int? rulesetId) { - Scheduler.Add(() => + handleRoomRequest(() => { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - user style is mostly for display. + if (user == null) + return; + + user.BeatmapId = beatmapId; + user.RulesetId = rulesetId; + + UserStyleChanged?.Invoke(user); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.UserModsChanged(int userId, IEnumerable mods) + { + handleRoomRequest(() => + { + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); // errors here are not critical - user mods are mostly for display. if (user == null) @@ -664,71 +820,66 @@ namespace osu.Game.Online.Multiplayer user.Mods = mods; + UserModsChanged?.Invoke(user); RoomUpdated?.Invoke(); - }, false); + }); return Task.CompletedTask; } Task IMultiplayerClient.LoadRequested() { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); LoadRequested?.Invoke(); - }, false); + }); return Task.CompletedTask; } Task IMultiplayerClient.GameplayAborted(GameplayAbortReason reason) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); GameplayAborted?.Invoke(reason); - }, false); + }); return Task.CompletedTask; } Task IMultiplayerClient.GameplayStarted() { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; + Debug.Assert(Room != null); + + foreach (var user in Room.Users) + user.VotedToSkipIntro = false; GameplayStarted?.Invoke(); - }, false); + }); return Task.CompletedTask; } Task IMultiplayerClient.ResultsReady() { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); ResultsReady?.Invoke(); - }, false); + }); return Task.CompletedTask; } public Task PlaylistItemAdded(MultiplayerPlaylistItem item) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); Debug.Assert(APIRoom != null); Room.Playlist.Add(item); @@ -743,11 +894,9 @@ namespace osu.Game.Online.Multiplayer public Task PlaylistItemRemoved(long playlistItemId) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); Debug.Assert(APIRoom != null); Room.Playlist.Remove(Room.Playlist.Single(existing => existing.ID == playlistItemId)); @@ -764,11 +913,9 @@ namespace osu.Game.Online.Multiplayer public Task PlaylistItemChanged(MultiplayerPlaylistItem item) { - Scheduler.Add(() => + handleRoomRequest(() => { - if (Room == null) - return; - + Debug.Assert(Room != null); Debug.Assert(APIRoom != null); Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; @@ -781,25 +928,59 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + Task IMultiplayerClient.UserVotedToSkipIntro(int userId) + { + handleRoomRequest(() => + { + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); + + // TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713. + if (user == null) + return; + + user.VotedToSkipIntro = true; + + UserVotedToSkipIntro?.Invoke(userId); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.VoteToSkipIntroPassed() + { + handleRoomRequest(() => + { + Debug.Assert(Room != null); + VoteToSkipIntroPassed?.Invoke(); + }); + + return Task.CompletedTask; + } + /// /// Populates the for a given collection of s. /// /// The s to populate. protected async Task PopulateUsers(IEnumerable multiplayerUsers) { - var request = new GetUsersRequest(multiplayerUsers.Select(u => u.UserID).Distinct().ToArray()); - - await API.PerformAsync(request).ConfigureAwait(false); - - if (request.Response == null) - return; - - Dictionary users = request.Response.Users.ToDictionary(user => user.Id); - - foreach (var multiplayerUser in multiplayerUsers) + foreach (int[] userChunk in multiplayerUsers.Select(u => u.UserID).Distinct().Chunk(GetUsersRequest.MAX_IDS_PER_REQUEST)) { - if (users.TryGetValue(multiplayerUser.UserID, out var user)) - multiplayerUser.User = user; + var request = new GetUsersRequest(userChunk); + + await API.PerformAsync(request).ConfigureAwait(false); + + if (request.Response == null) + return; + + Dictionary users = request.Response.Users.ToDictionary(user => user.Id); + + foreach (var multiplayerUser in multiplayerUsers) + { + if (users.TryGetValue(multiplayerUser.UserID, out var user)) + multiplayerUser.User = user; + } } } @@ -812,22 +993,20 @@ namespace osu.Game.Online.Multiplayer /// The new to update from. private void updateLocalRoomSettings(MultiplayerRoomSettings settings) { - if (Room == null) - return; - + Debug.Assert(Room != null); Debug.Assert(APIRoom != null); // Update a few properties of the room instantaneously. Room.Settings = settings; APIRoom.Name = Room.Settings.Name; APIRoom.Password = Room.Settings.Password; - APIRoom.Status = string.IsNullOrEmpty(Room.Settings.Password) ? new RoomStatusOpen() : new RoomStatusOpenPrivate(); APIRoom.Type = Room.Settings.MatchType; APIRoom.QueueMode = Room.Settings.QueueMode; APIRoom.AutoStartDuration = Room.Settings.AutoStartDuration; APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); APIRoom.AutoSkip = Room.Settings.AutoSkip; + SettingsChanged?.Invoke(settings); RoomUpdated?.Invoke(); } @@ -876,6 +1055,20 @@ namespace osu.Game.Online.Multiplayer return tcs.Task; } + private void handleRoomRequest(Action request) + { + Scheduler.Add(() => + { + if (Room == null) + { + pendingRequests.Enqueue(request); + return; + } + + request(); + }); + } + Task IStatefulUserHubClient.DisconnectRequested() { Schedule(() => @@ -885,5 +1078,91 @@ namespace osu.Game.Online.Multiplayer }); return Task.CompletedTask; } + + Task IMatchmakingClient.MatchmakingQueueJoined() + { + Scheduler.Add(() => MatchmakingQueueJoined?.Invoke()); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingQueueLeft() + { + Scheduler.Add(() => MatchmakingQueueLeft?.Invoke()); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingRoomInvited() + { + Scheduler.Add(() => MatchmakingRoomInvited?.Invoke()); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingRoomReady(long roomId, string password) + { + Scheduler.Add(() => MatchmakingRoomReady?.Invoke(roomId, password)); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status) + { + Scheduler.Add(() => MatchmakingLobbyStatusChanged?.Invoke(status)); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingQueueStatusChanged(MatchmakingQueueStatus status) + { + Scheduler.Add(() => MatchmakingQueueStatusChanged?.Invoke(status)); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingItemSelected(int userId, long playlistItemId) + { + Scheduler.Add(() => + { + MatchmakingItemSelected?.Invoke(userId, playlistItemId); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingItemDeselected(int userId, long playlistItemId) + { + Scheduler.Add(() => + { + MatchmakingItemDeselected?.Invoke(userId, playlistItemId); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + public abstract Task GetMatchmakingPools(); + + public abstract Task MatchmakingJoinLobby(); + + public abstract Task MatchmakingLeaveLobby(); + + public abstract Task MatchmakingJoinQueue(int poolId); + + public abstract Task MatchmakingLeaveQueue(); + + public abstract Task MatchmakingAcceptInvitation(); + + public abstract Task MatchmakingDeclineInvitation(); + + public abstract Task MatchmakingToggleSelection(long playlistItemId); + + public abstract Task MatchmakingSkipToNextStage(); + + private partial class MultiplayerInvitationNotification : UserAvatarNotification + { + protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; + + public MultiplayerInvitationNotification(APIUser user, Room room) + : base(user, NotificationsStrings.InvitedYouToTheMultiplayer(user.Username, room.Name)) + { + } + } } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs index d846e7f566..1cc5a8e70a 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; +using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Logging; namespace osu.Game.Online.Multiplayer @@ -16,12 +17,8 @@ namespace osu.Game.Online.Multiplayer { if (t.IsFaulted) { - Exception? exception = t.Exception; - - if (exception is AggregateException ae) - exception = ae.InnerException; - - Debug.Assert(exception != null); + Debug.Assert(t.Exception != null); + Exception exception = t.Exception.AsSingular(); if (exception.GetHubExceptionMessage() is string message) // Hub exceptions generally contain something we can show the user directly. diff --git a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs index c59f5937b0..bc2536848b 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs @@ -3,6 +3,7 @@ using System; using MessagePack; +using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer.Countdown; namespace osu.Game.Online.Multiplayer @@ -14,6 +15,7 @@ namespace osu.Game.Online.Multiplayer [Union(0, typeof(MatchStartCountdown))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. [Union(1, typeof(ForceGameplayStartCountdown))] [Union(2, typeof(ServerShuttingDownCountdown))] + [Union(3, typeof(MatchmakingStageCountdown))] public abstract class MultiplayerCountdown { /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index 00048fa931..3c02565fa1 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using MessagePack; using Newtonsoft.Json; using osu.Game.Online.Rooms; @@ -58,6 +59,12 @@ namespace osu.Game.Online.Multiplayer [Key(7)] public IList ActiveCountdowns { get; set; } = new List(); + /// + /// The ID of the chat channel for the room. + /// + [Key(8)] + public int ChannelID { get; set; } + [JsonConstructor] [SerializationConstructor] public MultiplayerRoom(long roomId) @@ -65,6 +72,28 @@ namespace osu.Game.Online.Multiplayer RoomID = roomId; } + public MultiplayerRoom(Room room) + { + RoomID = room.RoomID ?? 0; + ChannelID = room.ChannelId; + Settings = new MultiplayerRoomSettings(room); + Host = room.Host != null ? new MultiplayerRoomUser(room.Host.OnlineID) : null; + Playlist = room.Playlist.Select(p => new MultiplayerPlaylistItem(p)).ToArray(); + } + + /// + /// Retrieves the active as determined by the room's current settings. + /// + [IgnoreMember] + [JsonIgnore] + public MultiplayerPlaylistItem CurrentPlaylistItem => Playlist.Single(item => item.ID == Settings.PlaylistItemId); + + /// + /// Determines whether a user is able to add playlist items to this room. + /// + /// The user to check. + public bool CanAddPlaylistItems(MultiplayerRoomUser user) => user.Equals(Host) || Settings.QueueMode != QueueMode.HostOnly; + public override string ToString() => $"RoomID:{RoomID} Host:{Host?.UserID} Users:{Users.Count} State:{State} Settings: [{Settings}]"; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index c73b02874e..c264ec1eef 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -35,6 +35,20 @@ namespace osu.Game.Online.Multiplayer [IgnoreMember] public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero; + public MultiplayerRoomSettings() + { + } + + public MultiplayerRoomSettings(Room room) + { + Name = room.Name; + Password = room.Password ?? string.Empty; + MatchType = room.Type; + QueueMode = room.QueueMode; + AutoStartDuration = room.AutoStartDuration; + AutoSkip = room.AutoSkip; + } + public bool Equals(MultiplayerRoomSettings? other) { if (ReferenceEquals(this, other)) return true; diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index f769b4c805..d19386c98d 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -22,9 +22,6 @@ 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. /// @@ -37,6 +34,27 @@ namespace osu.Game.Online.Multiplayer [Key(3)] public IEnumerable Mods { get; set; } = Enumerable.Empty(); + [Key(4)] + public MatchUserState? MatchState { get; set; } + + /// + /// If not-null, a local override for this user's ruleset selection. + /// + [Key(5)] + public int? RulesetId; + + /// + /// If not-null, a local override for this user's beatmap selection. + /// + [Key(6)] + public int? BeatmapId; + + /// + /// Whether this user voted to skip the beatmap intro. + /// + [Key(7)] + public bool VotedToSkipIntro; + [IgnoreMember] public APIUser? User { get; set; } diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 40436d730e..1319578c06 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -14,6 +14,7 @@ using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays.Notifications; using osu.Game.Localisation; +using osu.Game.Online.Matchmaking; namespace osu.Game.Online.Multiplayer { @@ -32,7 +33,7 @@ namespace osu.Game.Online.Multiplayer public OnlineMultiplayerClient(EndpointConfiguration endpoints) { - endpoint = endpoints.MultiplayerEndpointUrl; + endpoint = endpoints.MultiplayerUrl; } [BackgroundDependencyLoader] @@ -60,6 +61,7 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.GameplayStarted), ((IMultiplayerClient)this).GameplayStarted); connection.On(nameof(IMultiplayerClient.GameplayAborted), ((IMultiplayerClient)this).GameplayAborted); connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); + connection.On(nameof(IMultiplayerClient.UserStyleChanged), ((IMultiplayerClient)this).UserStyleChanged); connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); connection.On(nameof(IMultiplayerClient.MatchRoomStateChanged), ((IMultiplayerClient)this).MatchRoomStateChanged); @@ -68,6 +70,18 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.PlaylistItemAdded), ((IMultiplayerClient)this).PlaylistItemAdded); connection.On(nameof(IMultiplayerClient.PlaylistItemRemoved), ((IMultiplayerClient)this).PlaylistItemRemoved); connection.On(nameof(IMultiplayerClient.PlaylistItemChanged), ((IMultiplayerClient)this).PlaylistItemChanged); + connection.On(nameof(IMultiplayerClient.UserVotedToSkipIntro), ((IMultiplayerClient)this).UserVotedToSkipIntro); + connection.On(nameof(IMultiplayerClient.VoteToSkipIntroPassed), ((IMultiplayerClient)this).VoteToSkipIntroPassed); + + connection.On(nameof(IMatchmakingClient.MatchmakingQueueJoined), ((IMatchmakingClient)this).MatchmakingQueueJoined); + connection.On(nameof(IMatchmakingClient.MatchmakingQueueLeft), ((IMatchmakingClient)this).MatchmakingQueueLeft); + connection.On(nameof(IMatchmakingClient.MatchmakingRoomInvited), ((IMatchmakingClient)this).MatchmakingRoomInvited); + connection.On(nameof(IMatchmakingClient.MatchmakingRoomReady), ((IMatchmakingClient)this).MatchmakingRoomReady); + connection.On(nameof(IMatchmakingClient.MatchmakingLobbyStatusChanged), ((IMatchmakingClient)this).MatchmakingLobbyStatusChanged); + connection.On(nameof(IMatchmakingClient.MatchmakingQueueStatusChanged), ((IMatchmakingClient)this).MatchmakingQueueStatusChanged); + connection.On(nameof(IMatchmakingClient.MatchmakingItemSelected), ((IMatchmakingClient)this).MatchmakingItemSelected); + connection.On(nameof(IMatchmakingClient.MatchmakingItemDeselected), ((IMatchmakingClient)this).MatchmakingItemDeselected); + connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested); }; @@ -75,7 +89,32 @@ namespace osu.Game.Online.Multiplayer } } - protected override async Task JoinRoom(long roomId, string? password = null) + protected override async Task CreateRoomInternal(MultiplayerRoom room) + { + if (!IsConnected.Value) + throw new OperationCanceledException(); + + Debug.Assert(connection != null); + + try + { + return await connection.InvokeAsync(nameof(IMultiplayerServer.CreateRoom), room).ConfigureAwait(false); + } + catch (HubException exception) + { + if (exception.GetHubExceptionMessage() == HubClientConnector.SERVER_SHUTDOWN_MESSAGE) + { + Debug.Assert(connector != null); + + await connector.Reconnect().ConfigureAwait(false); + return await CreateRoomInternal(room).ConfigureAwait(false); + } + + throw; + } + } + + protected override async Task JoinRoomInternal(long roomId, string? password = null) { if (!IsConnected.Value) throw new OperationCanceledException(); @@ -93,7 +132,7 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(connector != null); await connector.Reconnect().ConfigureAwait(false); - return await JoinRoom(roomId, password).ConfigureAwait(false); + return await JoinRoomInternal(roomId, password).ConfigureAwait(false); } throw; @@ -186,6 +225,16 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); } + public override Task ChangeUserStyle(int? beatmapId, int? rulesetId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserStyle), beatmapId, rulesetId); + } + public override Task ChangeUserMods(IEnumerable newMods) { if (!IsConnected.Value) @@ -266,6 +315,16 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.RemovePlaylistItem), playlistItemId); } + public override Task VoteToSkipIntro() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IMultiplayerServer.VoteToSkipIntro)); + } + public override Task DisconnectInternal() { if (connector == null) @@ -274,6 +333,87 @@ namespace osu.Game.Online.Multiplayer return connector.Disconnect(); } + public override Task GetMatchmakingPools() + { + if (!IsConnected.Value) + return Task.FromResult(Array.Empty()); + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.GetMatchmakingPools)); + } + + public override Task MatchmakingJoinLobby() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingJoinLobby)); + } + + public override Task MatchmakingLeaveLobby() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingLeaveLobby)); + } + + public override Task MatchmakingJoinQueue(int poolId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingJoinQueue), poolId); + } + + public override Task MatchmakingLeaveQueue() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingLeaveQueue)); + } + + public override Task MatchmakingAcceptInvitation() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingAcceptInvitation)); + } + + public override Task MatchmakingDeclineInvitation() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingDeclineInvitation)); + } + + public override Task MatchmakingToggleSelection(long playlistItemId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingToggleSelection), playlistItemId); + } + + public override Task MatchmakingSkipToNextStage() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingSkipToNextStage)); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index dda430ce6f..10d766c729 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -151,7 +151,7 @@ namespace osu.Game.Online base.Dispose(isDisposing); if (notificationsClient.IsNotNull()) - notificationsClient.MessageReceived += notifyAboutForcedDisconnection; + notificationsClient.MessageReceived -= notifyAboutForcedDisconnection; if (spectatorClient.IsNotNull()) spectatorClient.Disconnecting -= notifyAboutForcedDisconnection; diff --git a/osu.Game/Online/PersistentEndpointClientConnector.cs b/osu.Game/Online/PersistentEndpointClientConnector.cs index 9e7543ce2b..2674c29103 100644 --- a/osu.Game/Online/PersistentEndpointClientConnector.cs +++ b/osu.Game/Online/PersistentEndpointClientConnector.cs @@ -150,7 +150,7 @@ namespace osu.Game.Online // compare: https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/Online/BanchoClient.cs#L539 retryDelay = Math.Min(120000, (int)(retryDelay * 1.5)); - Logger.Log($"{ClientName} connect attempt failed: {exception.Message}. Next attempt in {thisDelay / 1000:N0} seconds.", LoggingTarget.Network); + Logger.Log($"{ClientName} connect attempt failed. Next attempt in {thisDelay / 1000:N0} seconds.\n{exception}", LoggingTarget.Network); await Task.Delay(thisDelay, cancellationToken).ConfigureAwait(false); } diff --git a/osu.Game/Online/ProductionEndpointConfiguration.cs b/osu.Game/Online/ProductionEndpointConfiguration.cs index 0244761b65..20583c8c7e 100644 --- a/osu.Game/Online/ProductionEndpointConfiguration.cs +++ b/osu.Game/Online/ProductionEndpointConfiguration.cs @@ -7,12 +7,13 @@ namespace osu.Game.Online { public ProductionEndpointConfiguration() { - WebsiteRootUrl = APIEndpointUrl = @"https://osu.ppy.sh"; + WebsiteUrl = APIUrl = @"https://osu.ppy.sh"; APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"; APIClientID = "5"; - SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator"; - MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer"; - MetadataEndpointUrl = "https://spectator.ppy.sh/metadata"; + SpectatorUrl = "https://spectator.ppy.sh/spectator"; + MultiplayerUrl = "https://spectator.ppy.sh/multiplayer"; + MetadataUrl = "https://spectator.ppy.sh/metadata"; + BeatmapSubmissionServiceUrl = "https://bss.ppy.sh"; } } } diff --git a/osu.Game/Online/Rooms/CreateRoomRequest.cs b/osu.Game/Online/Rooms/CreateRoomRequest.cs index 63a3b7bfa8..5b2ea77aad 100644 --- a/osu.Game/Online/Rooms/CreateRoomRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomRequest.cs @@ -15,6 +15,9 @@ namespace osu.Game.Online.Rooms public CreateRoomRequest(Room room) { Room = room; + + // Also copy back to the source model, since it is likely to have been stored elsewhere. + Success += r => Room.CopyFrom(r); } protected override WebRequest CreateWebRequest() @@ -23,7 +26,6 @@ namespace osu.Game.Online.Rooms req.ContentType = "application/json"; req.Method = HttpMethod.Post; - req.AddRaw(JsonConvert.SerializeObject(Room)); return req; diff --git a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs index e0f91032fd..eb2879ba6c 100644 --- a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs @@ -31,6 +31,7 @@ namespace osu.Game.Online.Rooms var req = base.CreateWebRequest(); req.Method = HttpMethod.Post; req.AddParameter("version_hash", versionHash); + req.AddParameter("beatmap_id", beatmapInfo.OnlineID.ToString(CultureInfo.InvariantCulture)); req.AddParameter("beatmap_hash", beatmapInfo.MD5Hash); req.AddParameter("ruleset_id", rulesetId.ToString(CultureInfo.InvariantCulture)); return req; diff --git a/osu.Game/Online/Rooms/GetRoomsRequest.cs b/osu.Game/Online/Rooms/GetRoomsRequest.cs index 7feb709acb..2d0d572e84 100644 --- a/osu.Game/Online/Rooms/GetRoomsRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomsRequest.cs @@ -11,28 +11,33 @@ namespace osu.Game.Online.Rooms { public class GetRoomsRequest : APIRequest> { - private readonly RoomStatusFilter status; + private readonly RoomModeFilter mode; + private readonly RoomStatusFilter? status; private readonly string category; - public GetRoomsRequest(RoomStatusFilter status, string category) + public GetRoomsRequest(FilterCriteria filterCriteria) { - this.status = status; - this.category = category; + mode = filterCriteria.Mode; + category = filterCriteria.Category; + status = filterCriteria.Status; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); - if (status != RoomStatusFilter.Open) - req.AddParameter("mode", status.ToString().ToSnakeCase().ToLowerInvariant()); + if (mode != RoomModeFilter.Open) + req.AddParameter(@"mode", mode.ToString().ToSnakeCase().ToLowerInvariant()); + + if (status != null) + req.AddParameter(@"status", status.Value.ToString().ToSnakeCase().ToLowerInvariant()); if (!string.IsNullOrEmpty(category)) - req.AddParameter("category", category); + req.AddParameter(@"category", category); return req; } - protected override string Target => "rooms"; + protected override string Target => @"rooms"; } } diff --git a/osu.Game/Online/Rooms/ItemAttemptsCount.cs b/osu.Game/Online/Rooms/ItemAttemptsCount.cs index dc86897660..9ea2235500 100644 --- a/osu.Game/Online/Rooms/ItemAttemptsCount.cs +++ b/osu.Game/Online/Rooms/ItemAttemptsCount.cs @@ -10,10 +10,22 @@ namespace osu.Game.Online.Rooms /// public class ItemAttemptsCount { + /// + /// The playlist item this object describes. + /// [JsonProperty("id")] public int PlaylistItemID { get; set; } + /// + /// The number of times the user attempted the playlist item. + /// [JsonProperty("attempts")] public int Attempts { get; set; } + + /// + /// Whether the user has a passing score on the playlist item. + /// + [JsonProperty("passed")] + public bool Passed { get; set; } } } diff --git a/osu.Game/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index dfc7a53fb2..610e887242 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -16,6 +16,9 @@ namespace osu.Game.Online.Rooms { Room = room; Password = password; + + // Also copy back to the source model, since it is likely to have been stored elsewhere. + Success += r => Room.CopyFrom(r); } protected override WebRequest CreateWebRequest() diff --git a/osu.Game/Online/Rooms/MatchType.cs b/osu.Game/Online/Rooms/MatchType.cs index 28f2da897a..bbfe25c8fd 100644 --- a/osu.Game/Online/Rooms/MatchType.cs +++ b/osu.Game/Online/Rooms/MatchType.cs @@ -15,7 +15,9 @@ namespace osu.Game.Online.Rooms [LocalisableDescription(typeof(MatchesStrings), nameof(MatchesStrings.MatchTeamTypesHeadToHead))] HeadToHead, - [LocalisableDescription(typeof(MatchesStrings), nameof(MatchesStrings.MatchTeamTypesTeamVs))] + [LocalisableDescription(typeof(MatchesStrings), nameof(MatchesStrings.MatchTeamTypesTeamVersus))] TeamVersus, + + Matchmaking } } diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index 8be703e620..3386b8654d 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -3,15 +3,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using MessagePack; using osu.Game.Online.API; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Online.Rooms { [Serializable] [MessagePackObject] - public class MultiplayerPlaylistItem + public class MultiplayerPlaylistItem : IEquatable { [Key(0)] public long ID { get; set; } @@ -28,9 +31,20 @@ namespace osu.Game.Online.Rooms [Key(4)] public int RulesetID { get; set; } + /// + /// Mods that should be applied for every participant in the room. + /// [Key(5)] public IEnumerable RequiredMods { get; set; } = Enumerable.Empty(); + /// + /// Mods that participants are allowed to apply at their own discretion. + /// + /// + /// This will be empty when is true, but participants may still select any mods from their choice of ruleset, + /// provided the mod implementation indicates free-mod validity + /// and is compatible with the rest of the user's selection. + /// [Key(6)] public IEnumerable AllowedMods { get; set; } = Enumerable.Empty(); @@ -56,9 +70,91 @@ namespace osu.Game.Online.Rooms [Key(10)] public double StarRating { get; set; } + /// + /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty, ruleset, and mods. + /// + [Key(11)] + public bool Freestyle { get; set; } + + /// + /// Creates a new . + /// [SerializationConstructor] public MultiplayerPlaylistItem() { } + + /// + /// Creates a new from an API . + /// + /// + /// This will create unique instances of the and arrays but NOT unique instances of the contained s. + /// + public MultiplayerPlaylistItem(PlaylistItem item) + { + ID = item.ID; + OwnerID = item.OwnerID; + BeatmapID = item.Beatmap.OnlineID; + BeatmapChecksum = item.Beatmap.MD5Hash; + RulesetID = item.RulesetID; + RequiredMods = item.RequiredMods.ToArray(); + AllowedMods = item.AllowedMods.ToArray(); + Expired = item.Expired; + PlaylistOrder = item.PlaylistOrder ?? 0; + PlayedAt = item.PlayedAt; + StarRating = item.Beatmap.StarRating; + Freestyle = item.Freestyle; + } + + /// + /// Creates a copy of this . + /// + /// + /// This will create unique instances of the and arrays but NOT unique instances of the contained s. + /// + public MultiplayerPlaylistItem Clone() + { + MultiplayerPlaylistItem clone = (MultiplayerPlaylistItem)MemberwiseClone(); + clone.RequiredMods = RequiredMods.ToArray(); + clone.AllowedMods = AllowedMods.ToArray(); + return clone; + } + + public bool Equals(MultiplayerPlaylistItem? other) + => other != null + && ID == other.ID + && OwnerID == other.OwnerID + && BeatmapID == other.BeatmapID + && BeatmapChecksum == other.BeatmapChecksum + && RulesetID == other.RulesetID + && RequiredMods.SequenceEqual(other.RequiredMods) + && AllowedMods.SequenceEqual(other.AllowedMods) + && Expired == other.Expired + && PlaylistOrder == other.PlaylistOrder + && PlayedAt == other.PlayedAt + && StarRating == other.StarRating + && Freestyle == other.Freestyle; + + public override bool Equals(object? obj) + => obj is MultiplayerPlaylistItem other && Equals(other); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(ID); + hashCode.Add(OwnerID); + hashCode.Add(BeatmapID); + hashCode.Add(BeatmapChecksum); + hashCode.Add(RulesetID); + hashCode.Add(RequiredMods); + hashCode.Add(AllowedMods); + hashCode.Add(Expired); + hashCode.Add(PlaylistOrder); + hashCode.Add(PlayedAt); + hashCode.Add(StarRating); + hashCode.Add(Freestyle); + return hashCode.ToHashCode(); + } } } diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index faa66c571d..74eaea8dbc 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -77,11 +77,17 @@ namespace osu.Game.Online.Rooms [CanBeNull] public MultiplayerScoresAround ScoresAround { get; set; } - public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) + [JsonProperty("ruleset_id")] + public int RulesetId { get; set; } + + [JsonProperty("beatmap_id")] + public int BeatmapId { get; set; } + + public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, [NotNull] BeatmapInfo beatmap) { - var ruleset = rulesets.GetRuleset(playlistItem.RulesetID); + var ruleset = rulesets.GetRuleset(RulesetId); if (ruleset == null) - throw new InvalidOperationException($"Couldn't create score with unknown ruleset: {playlistItem.RulesetID}"); + throw new InvalidOperationException($"Couldn't create score with unknown ruleset: {RulesetId}"); var rulesetInstance = ruleset.CreateInstance(); @@ -91,7 +97,7 @@ namespace osu.Game.Online.Rooms TotalScore = TotalScore, MaxCombo = MaxCombo, BeatmapInfo = beatmap, - Ruleset = rulesets.GetRuleset(playlistItem.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {playlistItem.RulesetID} not found locally"), + Ruleset = ruleset, Passed = Passed, Statistics = Statistics, MaximumStatistics = MaximumStatistics, diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 47d4e163bf..b7b6a2d7b3 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; @@ -9,6 +10,7 @@ using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Mods; using osu.Game.Utils; namespace osu.Game.Online.Rooms @@ -37,9 +39,19 @@ namespace osu.Game.Online.Rooms [JsonProperty("played_at")] public DateTimeOffset? PlayedAt { get; set; } + /// + /// Mods that participants are allowed to apply at their own discretion. + /// + /// + /// This will be empty when is true, but participants may still select any mods from their choice of ruleset, + /// provided the mod is compatible with the rest of the user's selection. + /// [JsonProperty("allowed_mods")] public APIMod[] AllowedMods { get; set; } = Array.Empty(); + /// + /// Mods that should be applied for every participant in the room. + /// [JsonProperty("required_mods")] public APIMod[] RequiredMods { get; set; } = Array.Empty(); @@ -67,6 +79,12 @@ namespace osu.Game.Online.Rooms set => Beatmap = new APIBeatmap { OnlineID = value }; } + /// + /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty, ruleset, and mods. + /// + [JsonProperty("freestyle")] + public bool Freestyle { get; set; } + /// /// A beatmap representing this playlist item. /// In many cases, this will *not* contain any usable information apart from OnlineID. @@ -79,6 +97,11 @@ namespace osu.Game.Online.Rooms private readonly Bindable valid = new BindableBool(true); + [JsonIgnore] + public IBindable Completed => completed; + + private readonly Bindable completed = new BindableBool(false); + [JsonConstructor] private PlaylistItem() : this(new APIBeatmap()) @@ -90,8 +113,14 @@ namespace osu.Game.Online.Rooms Beatmap = beatmap; } + /// + /// Creates a new from a . + /// + /// + /// This will create unique instances of the and arrays but NOT unique instances of the contained s. + /// public PlaylistItem(MultiplayerPlaylistItem item) - : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating }) + : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating, Checksum = item.BeatmapChecksum }) { ID = item.ID; OwnerID = item.OwnerID; @@ -101,10 +130,13 @@ namespace osu.Game.Online.Rooms PlayedAt = item.PlayedAt; RequiredMods = item.RequiredMods.ToArray(); AllowedMods = item.AllowedMods.ToArray(); + Freestyle = item.Freestyle; } public void MarkInvalid() => valid.Value = false; + public void MarkCompleted() => completed.Value = true; + #region Newtonsoft.Json implicit ShouldSerialize() methods // The properties in this region are used implicitly by Newtonsoft.Json to not serialise certain fields in some cases. @@ -120,18 +152,19 @@ namespace osu.Game.Online.Rooms #endregion - public PlaylistItem With(Optional id = default, Optional beatmap = default, Optional playlistOrder = default) + public PlaylistItem With(Optional id = default, Optional beatmap = default, Optional playlistOrder = default, Optional ruleset = default) { return new PlaylistItem(beatmap.GetOr(Beatmap)) { ID = id.GetOr(ID), OwnerID = OwnerID, - RulesetID = RulesetID, + RulesetID = ruleset.GetOr(RulesetID), Expired = Expired, PlaylistOrder = playlistOrder.GetOr(PlaylistOrder), PlayedAt = PlayedAt, AllowedMods = AllowedMods, RequiredMods = RequiredMods, + Freestyle = Freestyle, valid = { Value = Valid.Value }, }; } @@ -143,6 +176,7 @@ namespace osu.Game.Online.Rooms && Expired == other.Expired && PlaylistOrder == other.PlaylistOrder && AllowedMods.SequenceEqual(other.AllowedMods) - && RequiredMods.SequenceEqual(other.RequiredMods); + && RequiredMods.SequenceEqual(other.RequiredMods) + && Freestyle == other.Freestyle; } } diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 5a008bac13..dda069bba0 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -6,12 +6,10 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; -using System.Runtime.Serialization; using Newtonsoft.Json; using osu.Game.IO.Serialization.Converters; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms.RoomStatuses; namespace osu.Game.Online.Rooms { @@ -244,11 +242,11 @@ namespace osu.Game.Online.Rooms public int ChannelId { get => channelId; - private set => SetField(ref channelId, value); + set => SetField(ref channelId, value); } /// - /// The current room status. + /// The current status of the room. /// public RoomStatus Status { @@ -265,16 +263,10 @@ namespace osu.Game.Online.Rooms set => SetField(ref availability, value); } - [OnDeserialized] - private void onDeserialised(StreamingContext context) + public bool Pinned { - // API doesn't populate status so let's do it here. - if (EndDate != null && DateTimeOffset.Now >= EndDate) - Status = new RoomStatusEnded(); - else if (HasPassword) - Status = new RoomStatusOpenPrivate(); - else - Status = new RoomStatusOpen(); + get => pinned; + set => SetField(ref pinned, value); } [JsonProperty("id")] @@ -349,12 +341,34 @@ namespace osu.Game.Online.Rooms [JsonProperty("channel_id")] private int channelId; - // Not serialised (see: GetRoomsRequest). - private RoomStatus status = new RoomStatusOpen(); + [JsonProperty("status")] + [JsonConverter(typeof(SnakeCaseStringEnumConverter))] + private RoomStatus status; + + [JsonProperty("pinned")] + private bool pinned; // Not yet serialised (not implemented). private RoomAvailability availability; + public Room() + { + } + + public Room(MultiplayerRoom room) + { + RoomID = room.RoomID; + ChannelId = room.ChannelID; + Name = room.Settings.Name; + Password = room.Settings.Password; + Type = room.Settings.MatchType; + QueueMode = room.Settings.QueueMode; + AutoStartDuration = room.Settings.AutoStartDuration; + AutoSkip = room.Settings.AutoSkip; + Host = room.Host != null ? new APIUser { Id = room.Host.UserID } : null; + Playlist = room.Playlist.Select(p => new PlaylistItem(p)).ToArray(); + } + /// /// Copies values from another into this one. /// @@ -388,6 +402,15 @@ namespace osu.Game.Online.Rooms RecentParticipants = other.RecentParticipants; } + /// + /// Whether the room is no longer available. + /// + /// + /// This property does not update in real-time and needs to be queried periodically. + /// Subscribe to to be notified of any immediate changes. + /// + public bool HasEnded => DateTimeOffset.Now >= EndDate; + [JsonObject(MemberSerialization.OptIn)] public class RoomPlaylistItemStats { diff --git a/osu.Game/Online/Rooms/RoomExtensions.cs b/osu.Game/Online/Rooms/RoomExtensions.cs new file mode 100644 index 0000000000..b7348e8997 --- /dev/null +++ b/osu.Game/Online/Rooms/RoomExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API; + +namespace osu.Game.Online.Rooms +{ + public static class RoomExtensions + { + /// + /// Get the room page URL, or null if unavailable. + /// + public static string? GetOnlineURL(this Room room, IAPIProvider api) + { + if (!room.RoomID.HasValue) + return null; + + return $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{room.RoomID.Value}"; + } + } +} diff --git a/osu.Game/Online/Rooms/RoomStatus.cs b/osu.Game/Online/Rooms/RoomStatus.cs index 4b890b00b7..d048486f19 100644 --- a/osu.Game/Online/Rooms/RoomStatus.cs +++ b/osu.Game/Online/Rooms/RoomStatus.cs @@ -1,19 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using osu.Game.Graphics; -using osuTK.Graphics; - namespace osu.Game.Online.Rooms { - public abstract class RoomStatus + public enum RoomStatus { - public abstract string Message { get; } - public abstract Color4 GetAppropriateColour(OsuColour colours); - - public override int GetHashCode() => GetType().GetHashCode(); - public override bool Equals(object obj) => GetType() == obj?.GetType(); + Idle, + Playing, } } diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs deleted file mode 100644 index 0fc27d26b8..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusEnded : RoomStatus - { - 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 deleted file mode 100644 index 5cc664cf36..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusOpen : RoomStatus - { - public override string Message => "Open"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenLight; - } -} diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs deleted file mode 100644 index d71e706c76..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusOpenPrivate : RoomStatus - { - public override string Message => "Open (Private)"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenDark; - } -} diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs deleted file mode 100644 index 4d0c93b8ab..0000000000 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.RoomStatuses -{ - public class RoomStatusPlaying : RoomStatus - { - public override string Message => "Playing"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Purple; - } -} diff --git a/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs b/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs deleted file mode 100644 index 86708bee82..0000000000 --- a/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace osu.Game.Online -{ - /// - /// A type of that serializes a subset of types used in multiplayer/spectator communication that - /// derive from a known base type. This is a safe alternative to using or , - /// which are known to have security issues. - /// - public class SignalRDerivedTypeWorkaroundJsonConverter : JsonConverter - { - public override bool CanConvert(Type objectType) => - SignalRWorkaroundTypes.BASE_TYPE_MAPPING.Any(t => - objectType == t.baseType || - objectType == t.derivedType); - - public override object? ReadJson(JsonReader reader, Type objectType, object? o, JsonSerializer jsonSerializer) - { - if (reader.TokenType == JsonToken.Null) - return null; - - JObject obj = JObject.Load(reader); - - string type = (string)obj[@"$dtype"]!; - - var resolvedType = SignalRWorkaroundTypes.BASE_TYPE_MAPPING.Select(t => t.derivedType).Single(t => t.Name == type); - - object? instance = Activator.CreateInstance(resolvedType); - - if (instance != null) - jsonSerializer.Populate(obj["$value"]!.CreateReader(), instance); - - return instance; - } - - public override void WriteJson(JsonWriter writer, object? o, JsonSerializer serializer) - { - if (o == null) - { - writer.WriteNull(); - return; - } - - writer.WriteStartObject(); - - writer.WritePropertyName(@"$dtype"); - serializer.Serialize(writer, o.GetType().Name); - - writer.WritePropertyName(@"$value"); - writer.WriteRawValue(JsonConvert.SerializeObject(o)); - - writer.WriteEndObject(); - } - } -} diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index 59a12b3bf1..e509891486 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Users; @@ -14,7 +17,6 @@ namespace osu.Game.Online /// A static class providing the list of types requiring workarounds for serialisation in SignalR. /// /// - /// internal static class SignalRWorkaroundTypes { internal static readonly IReadOnlyList<(Type derivedType, Type baseType)> BASE_TYPE_MAPPING = new[] @@ -44,6 +46,17 @@ namespace osu.Game.Online (typeof(UserActivity.EditingBeatmap), typeof(UserActivity)), (typeof(UserActivity.ModdingBeatmap), typeof(UserActivity)), (typeof(UserActivity.TestingBeatmap), typeof(UserActivity)), + (typeof(UserActivity.InDailyChallengeLobby), typeof(UserActivity)), + (typeof(UserActivity.PlayingDailyChallenge), typeof(UserActivity)), + + // matchmaking + (typeof(MatchmakingQueueStatus.Searching), typeof(MatchmakingQueueStatus)), + (typeof(MatchmakingQueueStatus.MatchFound), typeof(MatchmakingQueueStatus)), + (typeof(MatchmakingQueueStatus.JoiningMatch), typeof(MatchmakingQueueStatus)), + (typeof(MatchmakingRoomState), typeof(MatchRoomState)), + (typeof(MatchmakingStageCountdown), typeof(MultiplayerCountdown)), + (typeof(MatchmakingAvatarActionRequest), typeof(MatchUserRequest)), + (typeof(MatchmakingAvatarActionEvent), typeof(MatchServerEvent)), }; } } diff --git a/osu.Game/Online/Spectator/ISpectatorClient.cs b/osu.Game/Online/Spectator/ISpectatorClient.cs index 2dc2283c23..2b73037cb8 100644 --- a/osu.Game/Online/Spectator/ISpectatorClient.cs +++ b/osu.Game/Online/Spectator/ISpectatorClient.cs @@ -37,5 +37,17 @@ namespace osu.Game.Online.Spectator /// The ID of the user who achieved the score. /// The ID of the score. Task UserScoreProcessed(int userId, long scoreId); + + /// + /// Signals that another user has started watching this client. + /// + /// The information about the user who started watching. + Task UserStartedWatching(SpectatorUser[] user); + + /// + /// Signals that another user has ended watching this client + /// + /// The ID of the user who ended watching. + Task UserEndedWatching(int userId); } } diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index 036cfa1d76..29d174f8e3 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -24,7 +24,7 @@ namespace osu.Game.Online.Spectator public OnlineSpectatorClient(EndpointConfiguration endpoints) { - endpoint = endpoints.SpectatorEndpointUrl; + endpoint = endpoints.SpectatorUrl; } [BackgroundDependencyLoader] @@ -42,6 +42,8 @@ namespace osu.Game.Online.Spectator connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); connection.On(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed); + connection.On(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching); + connection.On(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested); }; diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index fb7a3d13ca..f245e8cf3a 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -13,6 +14,7 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -36,14 +38,15 @@ namespace osu.Game.Online.Spectator public abstract IBindable IsConnected { get; } /// - /// The states of all users currently being watched. + /// The states of all users currently being watched by the local user. /// + [UsedImplicitly] // Marked virtual due to mock use in testing public virtual IBindableDictionary WatchedUserStates => watchedUserStates; /// - /// A global list of all players currently playing. + /// All users who are currently watching the local user. /// - public IBindableList PlayingUsers => playingUsers; + public IBindableList WatchingUsers => watchingUsers; /// /// Whether the local user is playing. @@ -53,6 +56,7 @@ namespace osu.Game.Online.Spectator /// /// Called whenever new frames arrive from the server. /// + [UsedImplicitly] // Marked virtual due to mock use in testing public virtual event Action? OnNewFrames; /// @@ -82,7 +86,7 @@ namespace osu.Game.Online.Spectator private readonly BindableDictionary watchedUserStates = new BindableDictionary(); - private readonly BindableList playingUsers = new BindableList(); + private readonly BindableList watchingUsers = new BindableList(); private readonly SpectatorState currentState = new SpectatorState(); private IBeatmap? currentBeatmap; @@ -92,7 +96,7 @@ namespace osu.Game.Online.Spectator private readonly Queue pendingFrameBundles = new Queue(); - private readonly Queue pendingFrames = new Queue(); + private readonly List pendingFrames = new List(); private double lastPurgeTime; @@ -125,8 +129,8 @@ namespace osu.Game.Online.Spectator } else { - playingUsers.Clear(); watchedUserStates.Clear(); + watchingUsers.Clear(); } }), true); } @@ -135,9 +139,6 @@ namespace osu.Game.Online.Spectator { Schedule(() => { - if (!playingUsers.Contains(userId)) - playingUsers.Add(userId); - if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; @@ -151,8 +152,6 @@ namespace osu.Game.Online.Spectator { Schedule(() => { - playingUsers.Remove(userId); - if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; @@ -179,9 +178,33 @@ namespace osu.Game.Online.Spectator return Task.CompletedTask; } + Task ISpectatorClient.UserStartedWatching(SpectatorUser[] users) + { + Schedule(() => + { + foreach (var user in users) + { + if (!watchingUsers.Contains(user)) + watchingUsers.Add(user); + } + }); + + return Task.CompletedTask; + } + + Task ISpectatorClient.UserEndedWatching(int userId) + { + Schedule(() => + { + watchingUsers.RemoveAll(u => u.OnlineID == userId); + }); + + return Task.CompletedTask; + } + Task IStatefulUserHubClient.DisconnectRequested() { - Schedule(() => DisconnectInternal()); + Schedule(() => DisconnectInternal().FireAndForget()); return Task.CompletedTask; } @@ -222,7 +245,16 @@ namespace osu.Game.Online.Spectator if (frame is IConvertibleReplayFrame convertible) { Debug.Assert(currentBeatmap != null); - pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); + + var convertedFrame = convertible.ToLegacy(currentBeatmap); + + // this reduces redundancy of frames in the resulting replay. + // it is also done at `ReplayRecorder`, but needs to be done here as well + // due to the flow being handled differently. + if (pendingFrames.LastOrDefault()?.IsEquivalentTo(convertedFrame) == true) + pendingFrames[^1] = convertedFrame; + else + pendingFrames.Add(convertedFrame); } if (pendingFrames.Count > max_pending_frames) @@ -259,7 +291,7 @@ namespace osu.Game.Online.Spectator else currentState.State = SpectatedUserState.Quit; - EndPlayingInternal(currentState); + EndPlayingInternal(currentState).FireAndForget(); }); } @@ -273,7 +305,7 @@ namespace osu.Game.Online.Spectator return; } - WatchUserInternal(userId); + WatchUserInternal(userId).FireAndForget(); } public void StopWatchingUser(int userId) @@ -290,7 +322,7 @@ namespace osu.Game.Online.Spectator watchedUsersRefCounts.Remove(userId); watchedUserStates.Remove(userId); - StopWatchingUserInternal(userId); + StopWatchingUserInternal(userId).FireAndForget(); }); } diff --git a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs index 3242e21994..7da0b4f279 100644 --- a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs +++ b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs @@ -39,6 +39,11 @@ namespace osu.Game.Online.Spectator /// public readonly BindableInt Combo = new BindableInt(); + /// + /// The highest combo achieved in the score thus far. + /// + public readonly BindableInt HighestCombo = new BindableInt(); + /// /// The used to calculate scores. /// @@ -157,6 +162,7 @@ namespace osu.Game.Online.Spectator Accuracy.Value = frame.Header.Accuracy; Combo.Value = frame.Header.Combo; + HighestCombo.Value = frame.Header.MaxCombo; TotalScore.Value = frame.Header.TotalScore; } diff --git a/osu.Game/Online/Spectator/SpectatorUser.cs b/osu.Game/Online/Spectator/SpectatorUser.cs new file mode 100644 index 0000000000..9c9563be70 --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorUser.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; +using osu.Game.Users; + +namespace osu.Game.Online.Spectator +{ + [Serializable] + [MessagePackObject] + public class SpectatorUser : IUser, IEquatable + { + [Key(0)] + public int OnlineID { get; set; } + + [Key(1)] + public string Username { get; set; } = string.Empty; + + [IgnoreMember] + public CountryCode CountryCode => CountryCode.Unknown; + + [IgnoreMember] + public bool IsBot => false; + + public bool Equals(SpectatorUser? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + return OnlineID == other.OnlineID; + } + + public override bool Equals(object? obj) => Equals(obj as SpectatorUser); + + // ReSharper disable once NonReadonlyMemberInGetHashCode + public override int GetHashCode() => OnlineID; + } +} diff --git a/osu.Game/Online/TrustedDomainOnlineStore.cs b/osu.Game/Online/TrustedDomainOnlineStore.cs new file mode 100644 index 0000000000..2b47f159e6 --- /dev/null +++ b/osu.Game/Online/TrustedDomainOnlineStore.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; + +namespace osu.Game.Online +{ + public sealed class TrustedDomainOnlineStore : OnlineStore + { + protected override string GetLookupUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) || !uri.Host.EndsWith(@".ppy.sh", StringComparison.OrdinalIgnoreCase)) + { + Logger.Log($@"Blocking resource lookup from external website: {url}", LoggingTarget.Network, LogLevel.Important); + return string.Empty; + } + + return url; + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index d8145c8246..4ea9fae183 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Humanizer; @@ -18,7 +17,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Configuration; -using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; @@ -47,7 +45,9 @@ using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Localisation; using osu.Game.Online; +using osu.Game.Online.API.Requests; using osu.Game.Online.Chat; +using osu.Game.Online.Leaderboards; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; @@ -57,24 +57,31 @@ using osu.Game.Overlays.Notifications; using osu.Game.Overlays.OSD; using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.Toolbar; -using osu.Game.Overlays.Volume; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Screens; using osu.Game.Screens.Edit; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Seasonal; using osu.Game.Skinning; using osu.Game.Updater; using osu.Game.Users; using osu.Game.Utils; +using osuTK; using osuTK.Graphics; using Sentry; +using IntroScreen = osu.Game.Screens.Menu.IntroScreen; +using MatchType = osu.Game.Online.Rooms.MatchType; namespace osu.Game { @@ -100,7 +107,15 @@ namespace osu.Game /// /// A common shear factor applied to most components of the game. /// - public const float SHEAR = 0.2f; + public static readonly Vector2 SHEAR = new Vector2(0.2f, 0); + + /// + /// For elements placed close to the screen edge, this is the margin to leave to the edge. + /// + public const float SCREEN_EDGE_MARGIN = 12f; + + private const double general_log_debounce = 60000; + private const string tablet_log_prefix = @"[Tablet] "; public Toolbar Toolbar { get; private set; } @@ -174,19 +189,14 @@ namespace osu.Game /// public readonly IBindable OverlayActivationMode = new Bindable(); - /// - /// Whether the back button is currently displayed. - /// - private readonly IBindable backButtonVisibility = new Bindable(); + IBindable ILocalUserPlayInfo.PlayingState => UserPlayingState; - IBindable ILocalUserPlayInfo.PlayingState => playingState; - - private readonly Bindable playingState = new Bindable(); + protected readonly Bindable UserPlayingState = new Bindable(); protected OsuScreenStack ScreenStack; - protected BackButton BackButton; - protected ScreenFooter ScreenFooter; + protected BackButton BackButton => screenStackFooter.BackButton; + protected ScreenFooter ScreenFooter => screenStackFooter.Footer; protected SettingsOverlay Settings; @@ -212,8 +222,14 @@ namespace osu.Game private Bindable uiScale; + private Bindable configUserActivity; + private Bindable configSkin; + private RealmDetachedBeatmapStore detachedBeatmapStore; + + private ScreenStackFooter screenStackFooter; + private readonly string[] args; private readonly List focusedOverlays = new List(); @@ -221,14 +237,31 @@ namespace osu.Game private readonly List visibleBlockingOverlays = new List(); + /// + /// Whether the game should be limited to only display officially licensed content. + /// + public virtual bool HideUnlicensedContent => false; + + private bool tabletLogNotifyOnWarning = true; + private bool tabletLogNotifyOnError = true; + private int generalLogRecentCount; + public OsuGame(string[] args = null) { this.args = args; - forwardGeneralLogsToNotifications(); - forwardTabletLogsToNotifications(); + Logger.NewEntry += forwardGeneralLogToNotifications; + Logger.NewEntry += forwardTabletLogToNotifications; - SentryLogger = new SentryLogger(this); + Schedule(() => + { + ITabletHandler tablet = Host.AvailableInputHandlers.OfType().SingleOrDefault(); + tablet?.Tablet.BindValueChanged(_ => + { + tabletLogNotifyOnWarning = true; + tabletLogNotifyOnError = true; + }, true); + }); } #region IOverlayManager @@ -296,13 +329,15 @@ namespace osu.Game foreach (var overlay in focusedOverlays) overlay.Hide(); + ScreenFooter.ActiveOverlay?.Hide(); + if (hideToolbar) Toolbar.Hide(); } protected override UserInputManager CreateUserInputManager() { var userInputManager = base.CreateUserInputManager(); - (userInputManager as OsuUserInputManager)?.PlayingState.BindTo(playingState); + (userInputManager as OsuUserInputManager)?.PlayingState.BindTo(UserPlayingState); return userInputManager; } @@ -314,46 +349,55 @@ namespace osu.Game private readonly List dragDropFiles = new List(); private ScheduledDelegate dragDropImportSchedule; + public override void SetupLogging(Storage gameStorage, Storage cacheStorage) + { + base.SetupLogging(gameStorage, cacheStorage); + SentryLogger = new SentryLogger(this, cacheStorage); + } + public override void SetHost(GameHost host) { base.SetHost(host); if (host.Window != null) { - host.Window.DragDrop += path => - { - // on macOS/iOS, URL associations are handled via SDL_DROPFILE events. - if (path.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal)) - { - HandleLink(path); - return; - } - - lock (dragDropFiles) - { - dragDropFiles.Add(path); - - Logger.Log($@"Adding ""{Path.GetFileName(path)}"" for import"); - - // File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms. - // In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch. - dragDropImportSchedule?.Cancel(); - dragDropImportSchedule = Scheduler.AddDelayed(handlePendingDragDropImports, 100); - } - }; + host.Window.CursorState |= CursorState.Hidden; + host.Window.DragDrop += onWindowDragDrop; } } - private void handlePendingDragDropImports() + private void onWindowDragDrop(string path) { + // on macOS/iOS, URL associations are handled via SDL_DROPFILE events. + if (path.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal)) + { + HandleLink(path); + return; + } + lock (dragDropFiles) { - Logger.Log($"Handling batch import of {dragDropFiles.Count} files"); + dragDropFiles.Add(path); - string[] paths = dragDropFiles.ToArray(); - dragDropFiles.Clear(); + Logger.Log($@"Adding ""{Path.GetFileName(path)}"" for import"); - Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning); + // File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms. + // In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch. + dragDropImportSchedule?.Cancel(); + dragDropImportSchedule = Scheduler.AddDelayed(handlePendingDragDropImports, 100); + } + + void handlePendingDragDropImports() + { + lock (dragDropFiles) + { + Logger.Log($"Handling batch import of {dragDropFiles.Count} files"); + + string[] paths = dragDropFiles.ToArray(); + dragDropFiles.Clear(); + + Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning); + } } } @@ -362,7 +406,10 @@ namespace osu.Game { SentryLogger.AttachUser(API.LocalUser); - dependencies.Cache(osuLogo = new OsuLogo { Alpha = 0 }); + if (SeasonalUIConfig.ENABLED) + dependencies.CacheAs(osuLogo = new OsuLogoChristmas { Alpha = 0 }); + else + dependencies.CacheAs(osuLogo = new OsuLogo { Alpha = 0 }); // bind config int to database RulesetInfo configRuleset = LocalConfig.GetBindable(OsuSetting.Ruleset); @@ -383,6 +430,8 @@ namespace osu.Game Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName; + configUserActivity = SessionStatics.GetBindable(Static.UserOnlineActivity); + configSkin = LocalConfig.GetBindable(OsuSetting.Skin); // Transfer skin from config to realm instance once on startup. @@ -391,7 +440,7 @@ namespace osu.Game // Transfer any runtime changes back to configuration file. SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); - playingState.BindValueChanged(p => + UserPlayingState.BindValueChanged(p => { BeatmapManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; SkinManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; @@ -404,6 +453,7 @@ namespace osu.Game SelectedMods.BindValueChanged(modsChanged); Beatmap.BindValueChanged(beatmapChanged, true); + configUserActivity.BindValueChanged(_ => updateWindowTitle()); applySafeAreaConsiderations = LocalConfig.GetBindable(OsuSetting.SafeAreaConsiderations); applySafeAreaConsiderations.BindValueChanged(apply => SafeAreaContainer.SafeAreaOverrideEdges = apply.NewValue ? SafeAreaOverrideEdges : Edges.All, true); @@ -464,7 +514,6 @@ namespace osu.Game HandleTimestamp(argString); break; - case LinkAction.JoinMultiplayerMatch: case LinkAction.Spectate: waitForReady(() => Notifications, _ => Notifications.Post(new SimpleNotification { @@ -491,48 +540,28 @@ namespace osu.Game else { string[] changelogArgs = argString.Split("/"); - ShowChangelogBuild(changelogArgs[0], changelogArgs[1]); + ShowChangelogBuild($"{changelogArgs[1]}-{changelogArgs[0]}"); } break; + case LinkAction.JoinRoom: + if (long.TryParse(argString, out long roomId)) + JoinRoom(roomId); + break; + default: throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action."); } }); - public void CopyUrlToClipboard(string url) => waitForReady(() => onScreenDisplay, _ => + public void CopyToClipboard(string value) => waitForReady(() => onScreenDisplay, _ => { - dependencies.Get().SetText(url); - onScreenDisplay.Display(new CopyUrlToast()); + dependencies.Get().SetText(value); + onScreenDisplay.Display(new CopiedToClipboardToast()); }); - public void OpenUrlExternally(string url, bool forceBypassExternalUrlWarning = false) => waitForReady(() => externalLinkOpener, _ => - { - bool isTrustedDomain; - - if (url.StartsWith('/')) - { - url = $"{API.WebsiteRootUrl}{url}"; - isTrustedDomain = true; - } - else - { - isTrustedDomain = url.StartsWith(API.WebsiteRootUrl, StringComparison.Ordinal); - } - - if (!url.CheckIsValidUrl()) - { - Notifications.Post(new SimpleErrorNotification - { - Text = NotificationsStrings.UnsupportedOrDangerousUrlProtocol(url), - }); - - return; - } - - externalLinkOpener.OpenUrlExternally(url, forceBypassExternalUrlWarning || isTrustedDomain); - }); + public void OpenUrlExternally(string url, LinkWarnMode warnMode = LinkWarnMode.Default) => waitForReady(() => externalLinkOpener, _ => externalLinkOpener.OpenUrlExternally(url, warnMode)); /// /// Open a specific channel in chat. @@ -592,9 +621,30 @@ namespace osu.Game /// /// 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)); + /// The build version, including stream suffix. + public void ShowChangelogBuild(string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(version)); + + /// + /// Joins a multiplayer or playlists room with the given . + /// + public void JoinRoom(long id) + { + var request = new GetRoomRequest(id); + request.Success += room => + { + switch (room.Type) + { + case MatchType.Playlists: + PresentPlaylist(room); + break; + + default: + PresentMultiplayerMatch(room, string.Empty); + break; + } + }; + API.Queue(request); + } /// /// Seeks to the provided if the editor is currently open. @@ -712,7 +762,7 @@ namespace osu.Game } }, validScreens: new[] { - typeof(SongSelect), typeof(IHandlePresentBeatmap) + typeof(SongSelect), typeof(Screens.SelectV2.SongSelect), typeof(IHandlePresentBeatmap) }); } @@ -723,6 +773,22 @@ namespace osu.Game /// The password to join the room, if any is given. public void PresentMultiplayerMatch(Room room, string password) { + if (room.HasEnded) + { + // TODO: Eventually it should be possible to display ended multiplayer rooms in game too, + // but it generally will require turning off the entirety of communication with spectator server which is currently embedded into multiplayer screens. + Notifications.Post(new SimpleNotification + { + Text = NotificationsStrings.MultiplayerRoomEnded, + Activated = () => + { + OpenUrlExternally($@"/multiplayer/rooms/{room.RoomID}"); + return true; + } + }); + return; + } + PerformFromScreen(screen => { if (!(screen is Multiplayer multiplayer)) @@ -734,6 +800,23 @@ namespace osu.Game // but `PerformFromScreen` doesn't understand nested stacks. } + /// + /// Join a playlist immediately. + /// + /// The playlist to join. + public void PresentPlaylist(Room room) + { + PerformFromScreen(screen => + { + if (!(screen is Playlists playlists)) + screen.Push(playlists = new Playlists()); + + playlists.Join(room); + }); + // TODO: We should really be able to use `validScreens: new[] { typeof(Playlists) }` here + // but `PerformFromScreen` doesn't understand nested stacks. + } + /// /// Present a score's replay immediately. /// The user should have already requested this interactively. @@ -742,23 +825,33 @@ namespace osu.Game { Logger.Log($"Beginning {nameof(PresentScore)} with score {score}"); - var databasedScore = ScoreManager.GetScore(score); + Score databasedScore; + + try + { + databasedScore = ScoreManager.GetScore(score); + } + catch (LegacyScoreDecoder.BeatmapNotFoundException notFound) + { + Logger.Log("The replay cannot be played because the beatmap is missing.", LoggingTarget.Information); + + var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); + req.Success += res => Notifications.Post(new MissingBeatmapNotification(res, notFound.Hash, null)); + API.Queue(req); + + return; + } if (databasedScore == null) return; if (databasedScore.Replay == null) { - Logger.Log("The loaded score has no replay data.", LoggingTarget.Information); + Logger.Log("The loaded score has no replay data.", LoggingTarget.Information, LogLevel.Important); return; } - var databasedBeatmap = BeatmapManager.QueryBeatmap(b => b.ID == databasedScore.ScoreInfo.BeatmapInfo.ID); - - if (databasedBeatmap == null) - { - Logger.Log("Tried to load a score for a beatmap we don't have!", LoggingTarget.Information); - return; - } + var databasedBeatmap = databasedScore.ScoreInfo.BeatmapInfo; + Debug.Assert(databasedBeatmap != null); // This should be able to be performed from song select always, but that is disabled for now // due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios). @@ -772,7 +865,7 @@ namespace osu.Game // which may not match the score, and thus crash. IEnumerable validScreens = Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap) && Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset) - ? new[] { typeof(SongSelect), typeof(DailyChallenge) } + ? new[] { typeof(SongSelect), typeof(Screens.SelectV2.SongSelect), typeof(DailyChallenge) } : Array.Empty(); PerformFromScreen(screen => @@ -791,6 +884,19 @@ namespace osu.Game if (!Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap)) Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); + var currentLeaderboard = LeaderboardManager.CurrentCriteria; + + bool leaderboardBeatmapMatches = currentLeaderboard != null && databasedBeatmap.Equals(currentLeaderboard.Beatmap); + bool leaderboardRulesetMatches = currentLeaderboard != null && databasedScore.ScoreInfo.Ruleset.Equals(currentLeaderboard.Ruleset); + + if (!leaderboardBeatmapMatches || !leaderboardRulesetMatches) + { + var newLeaderboard = currentLeaderboard != null + ? currentLeaderboard with { Beatmap = databasedBeatmap, Ruleset = databasedScore.ScoreInfo.Ruleset } + : new LeaderboardCriteria(databasedBeatmap, databasedScore.ScoreInfo.Ruleset, BeatmapLeaderboardScope.Global, null); + LeaderboardManager.FetchWithCriteria(newLeaderboard); + } + switch (presentType) { case ScorePresentType.Gameplay: @@ -822,6 +928,12 @@ namespace osu.Game protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); + /// + /// Adjust the globally applied in every . + /// Useful for changing how the game handles different aspect ratios. + /// + public virtual Vector2 ScalingContainerTargetDrawSize { get; } = new Vector2(1024, 768); + protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything); #region Beatmap progression @@ -830,6 +942,35 @@ namespace osu.Game { beatmap.OldValue?.CancelAsyncLoad(); beatmap.NewValue?.BeginAsyncLoad(); + updateWindowTitle(); + } + + private void updateWindowTitle() + { + if (Host.Window == null) + return; + + string newTitle; + + switch (configUserActivity.Value) + { + default: + newTitle = Name; + break; + + case UserActivity.InGame: + case UserActivity.TestingBeatmap: + case UserActivity.WatchingReplay: + newTitle = $"{Name} - {Beatmap.Value.BeatmapInfo.GetDisplayTitleRomanisable(true, false)}"; + break; + + case UserActivity.EditingBeatmap: + newTitle = $"{Name} - {Beatmap.Value.BeatmapInfo.Path ?? "new beatmap"}"; + break; + } + + if (newTitle != Host.Window.Title) + Host.Window.Title = newTitle; } private void modsChanged(ValueChangedEvent> mods) @@ -880,8 +1021,19 @@ namespace osu.Game protected override void Dispose(bool isDisposing) { + // Without this, tests may deadlock due to cancellation token not becoming cancelled before disposal. + // To reproduce, run `TestSceneButtonSystemNavigation` ensuring `TestConstructor` runs before `TestFastShortcutKeys`. + detachedBeatmapStore?.Dispose(); + base.Dispose(isDisposing); + SentryLogger.Dispose(); + + if (Host?.Window != null) + Host.Window.DragDrop -= onWindowDragDrop; + + Logger.NewEntry -= forwardGeneralLogToNotifications; + Logger.NewEntry -= forwardTabletLogToNotifications; } protected override IDictionary GetFrameworkConfigDefaults() @@ -901,9 +1053,6 @@ namespace osu.Game { base.LoadComplete(); - if (RuntimeInfo.EntryAssembly.GetCustomAttribute() == null) - Logger.Log(NotificationsStrings.NotOfficialBuild.ToString()); - var languages = Enum.GetValues(); var mappings = languages.Select(language => @@ -949,20 +1098,6 @@ namespace osu.Game MultiplayerClient.PostNotification = n => Notifications.Post(n); MultiplayerClient.PresentMatch = PresentMultiplayerMatch; - // 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. - LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; - - LocalConfig.LookupKeyBindings = l => - { - var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l); - - if (combinations.Count == 0) - return ToastStrings.NoKeyBound; - - return string.Join(" / ", combinations); - }; - ScreenFooter.BackReceptor backReceptor; dependencies.CacheAs(idleTracker = new GameIdleTracker(6000)); @@ -980,12 +1115,6 @@ namespace osu.Game AddRange(new Drawable[] { - new VolumeControlReceptor - { - RelativeSizeAxes = Axes.Both, - ActionRequested = action => volume.Adjust(action), - ScrollActionRequested = (action, amount, isPrecise) => volume.Adjust(action, amount, isPrecise), - }, ScreenOffsetContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -1000,13 +1129,9 @@ namespace osu.Game { backReceptor = new ScreenFooter.BackReceptor(), ScreenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, - BackButton = new BackButton(backReceptor) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Action = () => ScreenFooter.OnBack?.Invoke(), - }, logoContainer = new Container { RelativeSizeAxes = Axes.Both }, + // TODO: what is this? why is this? + // TODO: this is being screen scaled even though it's probably AN OVERLAY. footerBasedOverlayContent = new Container { Depth = -1, @@ -1016,17 +1141,11 @@ namespace osu.Game { Depth = -1, RelativeSizeAxes = Axes.Both, - Child = ScreenFooter = new ScreenFooter(backReceptor) + Child = screenStackFooter = new ScreenStackFooter(ScreenStack, backReceptor) { + // TODO: this is really really weird and should not exist. RequestLogoInFront = inFront => ScreenContainer.ChangeChildDepth(logoContainer, inFront ? float.MinValue : 0), - OnBack = () => - { - if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) - return; - - if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowUserExit && !currentScreen.OnBackButton())) - ScreenStack.Exit(); - } + BackButtonPressed = handleBackButton }, }, } @@ -1143,11 +1262,13 @@ namespace osu.Game loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add); loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); - loadComponentSingleFile(new DetachedBeatmapStore(), Add, true); + loadComponentSingleFile(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true); + loadComponentSingleFile(new QueueController(), Add, true); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); Add(new OnlineStatusNotifier(() => ScreenStack.CurrentScreen)); + Add(new FriendPresenceNotifier()); // side overlays which cancel each other. var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, FirstRunOverlay }; @@ -1194,18 +1315,20 @@ namespace osu.Game if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); }; - backButtonVisibility.ValueChanged += visible => - { - if (visible.NewValue) - BackButton.Show(); - else - BackButton.Hide(); - }; - // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. handleStartupImport(); } + private void handleBackButton() + { + // TODO: this is SUPER SUPER bad. + // It can potentially exit the wrong screen if screens are not loaded yet. + // ScreenFooter / ScreenBackButton should be aware of which screen it is currently being handled by. + if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) return; + + if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowUserExit && !currentScreen.OnBackButton())) ScreenStack.Exit(); + } + private void handleStartupImport() { if (args?.Length > 0) @@ -1246,115 +1369,90 @@ namespace osu.Game overlay.Depth = (float)-Clock.CurrentTime; } - private void forwardGeneralLogsToNotifications() + private void forwardGeneralLogToNotifications(LogEntry entry) { - int recentLogCount = 0; + if (entry.Level < LogLevel.Important || entry.Target > LoggingTarget.Database || entry.Target == null) return; - const double debounce = 60000; + if (entry.Exception is SentryOnlyDiagnosticsException) + return; - Logger.NewEntry += entry => + const int short_term_display_limit = 3; + + if (generalLogRecentCount < short_term_display_limit) { - if (entry.Level < LogLevel.Important || entry.Target > LoggingTarget.Database || entry.Target == null) return; - - if (entry.Exception is SentryOnlyDiagnosticsException) - return; - - const int short_term_display_limit = 3; - - 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), - })); - } - else if (recentLogCount == short_term_display_limit) + 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), + })); + } + else if (generalLogRecentCount == short_term_display_limit) + { + string logFile = Logger.GetLogger(entry.Target.Value).Filename; + + Schedule(() => Notifications.Post(new SimpleNotification { - string logFile = Logger.GetLogger(entry.Target.Value).Filename; - - Schedule(() => Notifications.Post(new SimpleNotification + Icon = FontAwesome.Solid.EllipsisH, + Text = NotificationsStrings.SubsequentMessagesLogged, + Activated = () => { - Icon = FontAwesome.Solid.EllipsisH, - Text = NotificationsStrings.SubsequentMessagesLogged, - Activated = () => - { - Logger.Storage.PresentFileExternally(logFile); - return true; - } - })); - } + Logger.Storage.PresentFileExternally(logFile); + return true; + } + })); + } - Interlocked.Increment(ref recentLogCount); - Scheduler.AddDelayed(() => Interlocked.Decrement(ref recentLogCount), debounce); - }; + Interlocked.Increment(ref generalLogRecentCount); + Scheduler.AddDelayed(() => Interlocked.Decrement(ref generalLogRecentCount), general_log_debounce); } - private void forwardTabletLogsToNotifications() + private void forwardTabletLogToNotifications(LogEntry entry) { - const string tablet_prefix = @"[Tablet] "; + if (entry.Level < LogLevel.Important || entry.Target != LoggingTarget.Input || !entry.Message.StartsWith(tablet_log_prefix, StringComparison.OrdinalIgnoreCase)) + return; - bool notifyOnWarning = true; - bool notifyOnError = true; + string message = entry.Message.Replace(tablet_log_prefix, string.Empty); - Logger.NewEntry += entry => + if (entry.Level == LogLevel.Error) { - if (entry.Level < LogLevel.Important || entry.Target != LoggingTarget.Input || !entry.Message.StartsWith(tablet_prefix, StringComparison.OrdinalIgnoreCase)) + if (!tabletLogNotifyOnError) return; - string message = entry.Message.Replace(tablet_prefix, string.Empty); + tabletLogNotifyOnError = false; - if (entry.Level == LogLevel.Error) + Schedule(() => { - if (!notifyOnError) - return; - - notifyOnError = false; - - Schedule(() => + Notifications.Post(new SimpleNotification { - Notifications.Post(new SimpleNotification - { - Text = NotificationsStrings.TabletSupportDisabledDueToError(message), - Icon = FontAwesome.Solid.PenSquare, - IconColour = Colours.RedDark, - }); - - // We only have one tablet handler currently. - // The loop here is weakly guarding against a future where more than one is added. - // If this is ever the case, this logic needs adjustment as it should probably only - // disable the relevant tablet handler rather than all. - foreach (var tabletHandler in Host.AvailableInputHandlers.OfType()) - tabletHandler.Enabled.Value = false; - }); - } - else if (notifyOnWarning) - { - Schedule(() => Notifications.Post(new SimpleNotification - { - Text = NotificationsStrings.EncounteredTabletWarning, + Text = NotificationsStrings.TabletSupportDisabledDueToError(message), Icon = FontAwesome.Solid.PenSquare, - IconColour = Colours.YellowDark, - Activated = () => - { - OpenUrlExternally("https://opentabletdriver.net/Tablets", true); - return true; - } - })); + IconColour = Colours.RedDark, + }); - notifyOnWarning = false; - } - }; - - Schedule(() => + // We only have one tablet handler currently. + // The loop here is weakly guarding against a future where more than one is added. + // If this is ever the case, this logic needs adjustment as it should probably only + // disable the relevant tablet handler rather than all. + foreach (var tabletHandler in Host.AvailableInputHandlers.OfType()) + tabletHandler.Enabled.Value = false; + }); + } + else if (tabletLogNotifyOnWarning) { - ITabletHandler tablet = Host.AvailableInputHandlers.OfType().SingleOrDefault(); - tablet?.Tablet.BindValueChanged(_ => + Schedule(() => Notifications.Post(new SimpleNotification { - notifyOnWarning = true; - notifyOnError = true; - }, true); - }); + Text = NotificationsStrings.EncounteredTabletWarning, + Icon = FontAwesome.Solid.PenSquare, + IconColour = Colours.YellowDark, + Activated = () => + { + OpenUrlExternally("https://opentabletdriver.net/Tablets", LinkWarnMode.NeverWarn); + return true; + } + })); + + tabletLogNotifyOnWarning = false; + } } private Task asyncLoadStream; @@ -1425,13 +1523,27 @@ namespace osu.Game public bool OnPressed(KeyBindingPressEvent e) { + switch (e.Action) + { + case GlobalAction.DecreaseVolume: + case GlobalAction.IncreaseVolume: + return volume.Adjust(e.Action); + } + + // All actions below this point don't allow key repeat. if (e.Repeat) return false; + // Wait until we're loaded at least to the intro before allowing various interactions. if (introScreen == null) return false; switch (e.Action) { + case GlobalAction.ToggleMute: + case GlobalAction.NextVolumeMeter: + case GlobalAction.PreviousVolumeMeter: + return volume.Adjust(e.Action); + case GlobalAction.ToggleFPSDisplay: fpsCounter.ToggleVisibility(); return true; @@ -1548,7 +1660,7 @@ namespace osu.Game GlobalCursorDisplay.ShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; } - private void screenChanged(IScreen current, IScreen newScreen) + protected virtual void ScreenChanged([CanBeNull] IOsuScreen current, [CanBeNull] IOsuScreen newScreen) { SentrySdk.ConfigureScope(scope => { @@ -1564,10 +1676,10 @@ namespace osu.Game switch (current) { case Player player: - player.PlayingState.UnbindFrom(playingState); + player.PlayingState.UnbindFrom(UserPlayingState); // reset for sanity. - playingState.Value = LocalUserPlayingState.NotPlaying; + UserPlayingState.Value = LocalUserPlayingState.NotPlaying; break; } @@ -1584,7 +1696,7 @@ namespace osu.Game break; case Player player: - player.PlayingState.BindTo(playingState); + player.PlayingState.BindTo(UserPlayingState); break; default: @@ -1592,47 +1704,35 @@ namespace osu.Game break; } - if (current is IOsuScreen currentOsuScreen) + if (current != null) { - backButtonVisibility.UnbindFrom(currentOsuScreen.BackButtonVisibility); - OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode); - API.Activity.UnbindFrom(currentOsuScreen.Activity); + OverlayActivationMode.UnbindFrom(current.OverlayActivationMode); + configUserActivity.UnbindFrom(current.Activity); } - if (newScreen is IOsuScreen newOsuScreen) + // Bind to new screen. + if (newScreen is OsuScreen newOsuScreen) { - backButtonVisibility.BindTo(newOsuScreen.BackButtonVisibility); - OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); - API.Activity.BindTo(newOsuScreen.Activity); + OverlayActivationMode.BindTo(newScreen.OverlayActivationMode); + configUserActivity.BindTo(newScreen.Activity); - GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newOsuScreen.HideMenuCursorOnNonMouseInput; + // Handle various configuration updates based on new screen settings. + GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newScreen.HideMenuCursorOnNonMouseInput; - if (newOsuScreen.HideOverlaysOnEnter) + if (newScreen.HideOverlaysOnEnter) CloseAllOverlays(); else Toolbar.Show(); - if (newOsuScreen.ShowFooter) - { - BackButton.Hide(); - ScreenFooter.SetButtons(newOsuScreen.CreateFooterButtons()); - ScreenFooter.Show(); - } - else - { - ScreenFooter.SetButtons(Array.Empty()); - ScreenFooter.Hide(); - } + skinEditor.SetTarget(newOsuScreen); } - - skinEditor.SetTarget((OsuScreen)newScreen); } - private void screenPushed(IScreen lastScreen, IScreen newScreen) => screenChanged(lastScreen, newScreen); + private void screenPushed(IScreen lastScreen, IScreen newScreen) => ScreenChanged((OsuScreen)lastScreen, (OsuScreen)newScreen); private void screenExited(IScreen lastScreen, IScreen newScreen) { - screenChanged(lastScreen, newScreen); + ScreenChanged((OsuScreen)lastScreen, (OsuScreen)newScreen); if (newScreen == null) Exit(); diff --git a/osu.Game/OsuGameBase_Importing.cs b/osu.Game/OsuGameBase.Importing.cs similarity index 100% rename from osu.Game/OsuGameBase_Importing.cs rename to osu.Game/OsuGameBase.Importing.cs diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 8027b6bfbc..222427cb60 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -49,6 +49,7 @@ using osu.Game.Localisation; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Chat; +using osu.Game.Online.Leaderboards; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; @@ -82,8 +83,6 @@ namespace osu.Game public const string OSU_PROTOCOL = "osu://"; - public const string CLIENT_STREAM_NAME = @"lazer"; - /// /// The filename of the main client database. /// @@ -91,7 +90,7 @@ namespace osu.Game public const int SAMPLE_CONCURRENCY = 6; - public const double SFX_STEREO_STRENGTH = 0.75; + public const double SFX_STEREO_STRENGTH = 0.6; /// /// Length of debounce (in milliseconds) for commonly occuring sample playbacks that could stack. @@ -108,6 +107,8 @@ namespace osu.Game public virtual EndpointConfiguration CreateEndpoints() => UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); + protected override OnlineStore CreateOnlineStore() => new TrustedDomainOnlineStore(); + public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); /// @@ -117,8 +118,6 @@ namespace osu.Game public bool IsDeployedBuild => AssemblyVersion.Major > 0; - internal const string BUILD_SUFFIX = "lazer"; - public virtual string Version { get @@ -126,8 +125,16 @@ namespace osu.Game if (!IsDeployedBuild) return @"local " + (DebugUtils.IsDebugBuild ? @"debug" : @"release"); - var version = AssemblyVersion; - return $@"{version.Major}.{version.Minor}.{version.Build}-{BUILD_SUFFIX}"; + string informationalVersion = Assembly.GetEntryAssembly()? + .GetCustomAttribute()? + .InformationalVersion; + + // Example: [assembly: AssemblyInformationalVersion("2025.613.0-tachyon+d934e574b2539e8787956c3c9ecce9dadebb10ee")] + if (!string.IsNullOrEmpty(informationalVersion)) + return informationalVersion.Split('+').First(); + + Version version = AssemblyVersion; + return $@"{version.Major}.{version.Minor}.{version.Build}-lazer"; } } @@ -201,6 +208,7 @@ namespace osu.Game private UserLookupCache userCache; private BeatmapLookupCache beatmapCache; + protected LeaderboardManager LeaderboardManager { get; private set; } private RulesetConfigCache rulesetConfigCache; @@ -278,7 +286,7 @@ namespace osu.Game dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.Renderer, Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); - largeStore.AddTextureSource(Host.CreateTextureLoaderStore(new OnlineStore())); + largeStore.AddTextureSource(Host.CreateTextureLoaderStore(CreateOnlineStore())); dependencies.Cache(largeStore); dependencies.CacheAs(LocalConfig); @@ -295,7 +303,7 @@ namespace osu.Game EndpointConfiguration endpoints = CreateEndpoints(); - MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; + MessageFormatter.WebsiteRootUrl = endpoints.WebsiteUrl; frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); frameworkLocale.BindValueChanged(_ => updateLanguage()); @@ -315,6 +323,7 @@ namespace osu.Game dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, API, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); + dependencies.CacheAs(BeatmapManager); dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); @@ -362,6 +371,9 @@ namespace osu.Game dependencies.CacheAs>(Beatmap); dependencies.CacheAs(Beatmap); + dependencies.Cache(LeaderboardManager = new LeaderboardManager()); + base.Content.Add(LeaderboardManager); + // add api components to hierarchy. if (API is APIAccess apiAccess) base.Content.Add(apiAccess); @@ -418,26 +430,32 @@ namespace osu.Game Ruleset.BindValueChanged(onRulesetChanged); Beatmap.BindValueChanged(onBeatmapChanged); + + // 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. + LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; + LocalConfig.LookupKeyBindings = l => KeyBindingStore.GetBindingsStringFor(l); } private void updateLanguage() => CurrentLanguage.Value = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); private void addFilesWarning() { - var realmStore = new RealmFileStore(realm, Storage); - const string filename = "IMPORTANT READ ME.txt"; - if (!realmStore.Storage.Exists(filename)) + if (!Storage.Exists(filename)) { - using (var stream = realmStore.Storage.CreateFileSafely(filename)) + using (var stream = Storage.CreateFileSafely(filename)) using (var textWriter = new StreamWriter(stream)) { - textWriter.WriteLine(@"This folder contains all your user files (beatmaps, skins, replays etc.)"); - textWriter.WriteLine(@"Please do not touch or delete this folder!!"); + textWriter.WriteLine(@"This folder contains all your user files and configuration."); + textWriter.WriteLine(@"Please DO NOT make manual changes to this folder."); textWriter.WriteLine(); - textWriter.WriteLine(@"If you are really looking to completely delete user data, please delete"); - textWriter.WriteLine(@"the parent folder including all other files and directories"); + textWriter.WriteLine(@"- If you want to back up your game files, please back up THE ENTIRETY OF THIS DIRECTORY."); + textWriter.WriteLine(@"- If you want to delete all of your game files, please delete THE ENTIRETY OF THIS DIRECTORY."); + textWriter.WriteLine(); + textWriter.WriteLine(@"To be very clear, the ""files/"" directory inside this directory stores all the raw pieces of your beatmaps, skins, and replays."); + textWriter.WriteLine(@"Importantly, it is NOT the only directory you need a backup of to avoid losing data. If you copy only the ""files/"" directory, YOU WILL LOSE DATA."); textWriter.WriteLine(); textWriter.WriteLine(@"For more information on how these files are organised,"); textWriter.WriteLine(@"see https://github.com/ppy/osu/wiki/User-file-storage"); @@ -449,8 +467,6 @@ namespace osu.Game protected virtual void InitialiseFonts() { - AddFont(Resources, @"Fonts/osuFont"); - AddFont(Resources, @"Fonts/Torus/Torus-Regular"); AddFont(Resources, @"Fonts/Torus/Torus-Light"); AddFont(Resources, @"Fonts/Torus/Torus-SemiBold"); @@ -471,9 +487,10 @@ namespace osu.Game AddFont(Resources, @"Fonts/Inter/Inter-BoldItalic"); AddFont(Resources, @"Fonts/Noto/Noto-Basic"); - AddFont(Resources, @"Fonts/Noto/Noto-Hangul"); + AddFont(Resources, @"Fonts/Noto/Noto-Bopomofo"); AddFont(Resources, @"Fonts/Noto/Noto-CJK-Basic"); AddFont(Resources, @"Fonts/Noto/Noto-CJK-Compatibility"); + AddFont(Resources, @"Fonts/Noto/Noto-Hangul"); AddFont(Resources, @"Fonts/Noto/Noto-Thai"); AddFont(Resources, @"Fonts/Venera/Venera-Light"); diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index fb6a5796a1..b2b672342e 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -18,6 +18,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Chat; using osu.Game.Overlays.Settings; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -213,7 +214,7 @@ namespace osu.Game.Overlays.AccountCreation if (!string.IsNullOrEmpty(errors.Message)) passwordDescription.AddErrors(new[] { errors.Message }); - game?.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", true); + game?.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", LinkWarnMode.NeverWarn); } } else diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index bab64165cb..72d7e0c752 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -29,7 +27,7 @@ namespace osu.Game.Overlays.BeatmapListing /// /// Any time the text box receives key events (even while masked). /// - public Action TypingStarted; + public Action? TypingStarted; public Bindable Query => textBox.Current; @@ -51,7 +49,7 @@ namespace osu.Game.Overlays.BeatmapListing public Bindable ExplicitContent => explicitContentFilter.Current; - public APIBeatmapSet BeatmapSet + public APIBeatmapSet? BeatmapSet { set { @@ -67,7 +65,7 @@ namespace osu.Game.Overlays.BeatmapListing } private readonly BeatmapSearchTextBox textBox; - private readonly BeatmapSearchMultipleSelectionFilterRow generalFilter; + private readonly BeatmapSearchGeneralFilterRow generalFilter; private readonly BeatmapSearchRulesetFilterRow modeFilter; private readonly BeatmapSearchFilterRow categoryFilter; private readonly BeatmapSearchFilterRow genreFilter; @@ -151,7 +149,7 @@ namespace osu.Game.Overlays.BeatmapListing categoryFilter.Current.Value = SearchCategory.Leaderboard; } - private IBindable allowExplicitContent; + private IBindable allowExplicitContent = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, OsuConfigManager config) @@ -165,6 +163,13 @@ namespace osu.Game.Overlays.BeatmapListing }, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + generalFilter.Ruleset.BindTo(Ruleset); + } + public void TakeFocus() => textBox.TakeFocus(); private partial class BeatmapSearchTextBox : BasicSearchTextBox @@ -172,7 +177,7 @@ namespace osu.Game.Overlays.BeatmapListing /// /// Any time the text box receives key events (even while masked). /// - public Action TextChanged; + public Action? TextChanged; protected override Color4 SelectionColour => Color4.Gray; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs index 2d56c60de6..b62836dfde 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs @@ -1,18 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Overlays.Dialog; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Utils; using osuTK.Graphics; using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; @@ -20,27 +26,97 @@ namespace osu.Game.Overlays.BeatmapListing { public partial class BeatmapSearchGeneralFilterRow : BeatmapSearchMultipleSelectionFilterRow { + public readonly IBindable Ruleset = new Bindable(); + public BeatmapSearchGeneralFilterRow() : base(BeatmapsStrings.ListingSearchFiltersGeneral) { } - protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter(); + protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter + { + Ruleset = { BindTarget = Ruleset } + }; private partial class GeneralFilter : MultipleSelectionFilter { + public readonly IBindable Ruleset = new Bindable(); + protected override MultipleSelectionFilterTabItem CreateTabItem(SearchGeneral value) { - if (value == SearchGeneral.FeaturedArtists) - return new FeaturedArtistsTabItem(); + switch (value) + { + case SearchGeneral.Recommended: + return new RecommendedDifficultyTabItem + { + Ruleset = { BindTarget = Ruleset } + }; - return new MultipleSelectionFilterTabItem(value); + case SearchGeneral.FeaturedArtists: + return new FeaturedArtistsTabItem(); + + default: + return new MultipleSelectionFilterTabItem(value); + } } } - private partial class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem + private partial class RecommendedDifficultyTabItem : MultipleSelectionFilterTabItem { - private Bindable disclaimerShown; + public readonly IBindable Ruleset = new Bindable(); + + [Resolved] + private DifficultyRecommender? recommender { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + public RecommendedDifficultyTabItem() + : base(SearchGeneral.Recommended) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (recommender != null) + recommender.StarRatingUpdated += updateText; + + Ruleset.BindValueChanged(_ => updateText(), true); + } + + private void updateText() + { + // fallback to profile default game mode if beatmap listing mode filter is set to Any + // TODO: find a way to update `PlayMode` when the profile default game mode has changed + RulesetInfo? ruleset = Ruleset.Value.IsLegacyRuleset() ? Ruleset.Value : rulesets.GetRuleset(api.LocalUser.Value.PlayMode); + + if (ruleset == null) return; + + double? starRating = recommender?.GetRecommendedStarRatingFor(ruleset); + + if (starRating != null) + Text.Text = LocalisableString.Interpolate($"{Value.GetLocalisableDescription()} ({starRating.Value.FormatStarRating()})"); + else + Text.Text = Value.GetLocalisableDescription(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (recommender != null) + recommender.StarRatingUpdated -= updateText; + } + } + + private partial class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem, IHasTooltip + { + private Bindable disclaimerShown = null!; public FeaturedArtistsTabItem() : base(SearchGeneral.FeaturedArtists) @@ -48,19 +124,38 @@ namespace osu.Game.Overlays.BeatmapListing } [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private SessionStatics sessionStatics { get; set; } + private OsuConfigManager config { get; set; } = null!; - [Resolved(canBeNull: true)] - private IDialogOverlay dialogOverlay { get; set; } + [Resolved] + private SessionStatics sessionStatics { get; set; } = null!; + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private OsuGame? game { get; set; } + + public LocalisableString TooltipText => BeatmapOverlayStrings.FeaturedArtistsTooltip; protected override void LoadComplete() { base.LoadComplete(); + config.BindWith(OsuSetting.BeatmapListingFeaturedArtistFilter, Active); disclaimerShown = sessionStatics.GetBindable(Static.FeaturedArtistDisclaimerShownOnce); + + // no need to show the disclaimer if the user already had it toggled off in config. + if (!Active.Value) + disclaimerShown.Value = true; + + if (game?.HideUnlicensedContent == true) + { + Enabled.Value = false; + Active.Disabled = true; + } } protected override Color4 ColourNormal => colours.Orange1; @@ -68,6 +163,9 @@ namespace osu.Game.Overlays.BeatmapListing protected override bool OnClick(ClickEvent e) { + if (!Enabled.Value) + return true; + if (!disclaimerShown.Value && dialogOverlay != null) { dialogOverlay.Push(new FeaturedArtistConfirmDialog(() => diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs index 958297b559..73af62c322 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs @@ -73,7 +73,10 @@ namespace osu.Game.Overlays.BeatmapListing private void currentChanged(object? sender, NotifyCollectionChangedEventArgs e) { foreach (var c in Children) - c.Active.Value = Current.Contains(c.Value); + { + if (!c.Active.Disabled) + c.Active.Value = Current.Contains(c.Value); + } } /// @@ -100,7 +103,7 @@ namespace osu.Game.Overlays.BeatmapListing protected partial class MultipleSelectionFilterTabItem : FilterTabItem { - private Drawable activeContent = null!; + private Container activeContent = null!; private Circle background = null!; public MultipleSelectionFilterTabItem(T value) @@ -160,7 +163,9 @@ namespace osu.Game.Overlays.BeatmapListing { Color4 colour = Active.Value ? ColourActive : ColourNormal; - if (IsHovered) + if (!Enabled.Value) + colour = colour.Darken(1f); + else if (IsHovered) colour = Active.Value ? colour.Darken(0.2f) : colour.Lighten(0.2f); if (Active.Value) diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs index 8f4ecaa0f5..e357718103 100644 --- a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -57,7 +57,9 @@ namespace osu.Game.Overlays.BeatmapListing { base.LoadComplete(); + Enabled.BindValueChanged(_ => UpdateState()); UpdateState(); + FinishTransforms(true); } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs index d18e1c93c9..c9783d42dc 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs @@ -39,7 +39,6 @@ namespace osu.Game.Overlays.BeatmapSet }, textContainer = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: 14)) { - Direction = FillDirection.Full, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding(10), diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index 5f021803b0..f2630caa83 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -12,7 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; -using osu.Game.Beatmaps; +using osu.Framework.Localisation; using osu.Game.Beatmaps.Drawables; using osu.Game.Extensions; using osu.Game.Graphics; @@ -21,6 +21,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; +using osu.Game.Utils; using osuTK; namespace osu.Game.Overlays.BeatmapSet @@ -30,15 +31,14 @@ namespace osu.Game.Overlays.BeatmapSet private const float tile_icon_padding = 7; private const float tile_spacing = 2; - private readonly OsuSpriteText version, starRating, starRatingText; - private readonly LinkFlowContainer guestMapperContainer; - private readonly FillFlowContainer starRatingContainer; + private readonly LinkFlowContainer infoContainer; private readonly Statistic plays, favourites; public readonly DifficultiesContainer Difficulties; public readonly Bindable Beatmap = new Bindable(); private APIBeatmapSet? beatmapSet; + private readonly Box background; public APIBeatmapSet? BeatmapSet { @@ -52,6 +52,9 @@ namespace osu.Game.Overlays.BeatmapSet } } + [Resolved] + private OsuColour colours { get; set; } = null!; + public BeatmapPicker() { RelativeSizeAxes = Axes.X; @@ -66,64 +69,35 @@ namespace osu.Game.Overlays.BeatmapSet Direction = FillDirection.Vertical, Children = new Drawable[] { - Difficulties = new DifficultiesContainer + new Container + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Left = -(tile_icon_padding + tile_spacing / 2), Bottom = 10 }, + Children = new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Child = background = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.5f + } + }, + Difficulties = new DifficultiesContainer + { + AutoSizeAxes = Axes.Both, + OnLostHover = () => showBeatmap(Beatmap.Value, withStarRating: false), + }, + } + }, + infoContainer = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 11)) { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Left = -(tile_icon_padding + tile_spacing / 2), Bottom = 10 }, - OnLostHover = () => - { - showBeatmap(Beatmap.Value); - starRatingContainer.FadeOut(100); - }, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5f), - Children = new Drawable[] - { - version = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold) - }, - guestMapperContainer = new LinkFlowContainer(s => - s.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 11)) - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Bottom = 1 }, - }, - starRatingContainer = new FillFlowContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - 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, - }, - } - }, - }, + TextAnchor = Anchor.BottomLeft, }, new FillFlowContainer { @@ -143,7 +117,7 @@ namespace osu.Game.Overlays.BeatmapSet Beatmap.ValueChanged += b => { - showBeatmap(b.NewValue); + showBeatmap(b.NewValue, withStarRating: Difficulties.Any(d => d.IsHovered)); updateDifficultyButtons(); }; } @@ -152,11 +126,10 @@ namespace osu.Game.Overlays.BeatmapSet private IBindable ruleset { get; set; } = null!; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - starRating.Colour = colours.Yellow; - starRatingText.Colour = colours.Yellow; updateDisplay(); + background.Colour = colourProvider.Background3; } protected override void LoadComplete() @@ -169,6 +142,12 @@ namespace osu.Game.Overlays.BeatmapSet Beatmap.TriggerChange(); } + protected override void Update() + { + base.Update(); + Difficulties.MaximumSize = new Vector2(DrawWidth, float.MaxValue); + } + private void updateDisplay() { Difficulties.Clear(); @@ -184,16 +163,12 @@ namespace osu.Game.Overlays.BeatmapSet State = DifficultySelectorState.NotSelected, OnHovered = beatmap => { - showBeatmap(beatmap); - starRating.Text = beatmap.StarRating.ToLocalisableString(@"0.00"); - starRatingContainer.FadeIn(100); + showBeatmap(beatmap, withStarRating: true); }, OnClicked = beatmap => { Beatmap.Value = beatmap; }, }); } - starRatingContainer.FadeOut(100); - // If a selection is already made, try and maintain it. if (Beatmap.Value != null) Beatmap.Value = Difficulties.FirstOrDefault(b => b.Beatmap.OnlineID == Beatmap.Value.OnlineID)?.Beatmap; @@ -207,22 +182,68 @@ namespace osu.Game.Overlays.BeatmapSet updateDifficultyButtons(); } - private void showBeatmap(APIBeatmap? beatmapInfo) + private void showBeatmap(APIBeatmap? beatmapInfo, bool withStarRating) { - guestMapperContainer.Clear(); + infoContainer.Clear(); - if (beatmapInfo?.AuthorID != BeatmapSet?.AuthorID) + infoContainer.AddText(beatmapInfo?.DifficultyName ?? string.Empty, s => s.Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold)); + infoContainer.AddArbitraryDrawable(Empty().With(e => e.Width = 5)); + + var beatmapOwners = beatmapInfo?.BeatmapOwners; + bool isHostDifficulty = beatmapOwners?.Length == 1 && beatmapOwners.First().Id == beatmapSet?.AuthorID; + + if (beatmapOwners != null && !isHostDifficulty) { - APIUser? user = BeatmapSet?.RelatedUsers?.SingleOrDefault(u => u.OnlineID == beatmapInfo?.AuthorID); + APIUser[] users = BeatmapSet?.RelatedUsers?.Where(u => beatmapOwners.Any(o => o.Id == u.OnlineID)).ToArray() ?? []; + int count = users.Length; - if (user != null) + switch (count) { - guestMapperContainer.AddText("mapped by "); - guestMapperContainer.AddUserLink(user); + case 0: + break; + + case 1: + infoContainer.AddText(BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty)); + infoContainer.AddUserLink(users[0]); + break; + + case 2: + infoContainer.AddText(BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty)); + infoContainer.AddUserLink(users[0]); + infoContainer.AddText(CommonStrings.ArrayAndTwoWordsConnector); + infoContainer.AddUserLink(users[1]); + break; + + default: + { + infoContainer.AddText(BeatmapsetsStrings.ShowDetailsMappedBy(string.Empty)); + + for (int i = 0; i < count; i++) + { + infoContainer.AddUserLink(users[i]); + + if (i < count - 2) + infoContainer.AddText(CommonStrings.ArrayAndWordsConnector); + else if (i == count - 2) + infoContainer.AddText(CommonStrings.ArrayAndLastWordConnector); + } + + break; + } } } - version.Text = beatmapInfo?.DifficultyName ?? string.Empty; + if (withStarRating) + { + infoContainer.AddArbitraryDrawable(Empty().With(e => e.Width = 5)); + infoContainer.AddText( + LocalisableString.Interpolate($"{BeatmapsetsStrings.ShowStatsStars} {beatmapInfo?.StarRating.FormatStarRating()}"), + t => + { + t.Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold); + t.Colour = colours.Yellow; + }); + } } private void updateDifficultyButtons() @@ -244,8 +265,8 @@ namespace osu.Game.Overlays.BeatmapSet public partial class DifficultySelectorButton : OsuClickableContainer, IStateful { private const float transition_duration = 100; - private const float size = 54; - private const float background_size = size - 2; + private const float size = 40; + private const float background_size = size - 1; private readonly Container background; private readonly Box backgroundBox; @@ -280,7 +301,6 @@ namespace osu.Game.Overlays.BeatmapSet { Beatmap = beatmapInfo; Size = new Vector2(size); - Margin = new MarginPadding { Horizontal = tile_spacing / 2 }; Children = new Drawable[] { @@ -288,7 +308,8 @@ namespace osu.Game.Overlays.BeatmapSet { Size = new Vector2(background_size), Masking = true, - CornerRadius = 4, + CornerRadius = 10, + BorderThickness = 3, Child = backgroundBox = new Box { RelativeSizeAxes = Axes.Both, @@ -298,7 +319,6 @@ namespace osu.Game.Overlays.BeatmapSet icon = new DifficultyIcon(beatmapInfo, ruleset) { TooltipType = DifficultyIconTooltipType.None, - Current = { Value = new StarDifficulty(beatmapInfo.StarRating, 0) }, Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(size - tile_icon_padding * 2), @@ -343,6 +363,7 @@ namespace osu.Game.Overlays.BeatmapSet private void load(OverlayColourProvider colourProvider) { backgroundBox.Colour = colourProvider.Background6; + background.BorderColour = colourProvider.Light2; } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index a50043f0f0..f75e7b1d3c 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -46,6 +47,8 @@ namespace osu.Game.Overlays.BeatmapSet private readonly Box coverGradient; private readonly LinkFlowContainer title, artist; private readonly AuthorInfo author; + private readonly VideoIconPill videoIconPill; + private readonly StoryboardIconPill storyboardIconPill; private ExternalLinkButton externalLink; @@ -98,7 +101,7 @@ namespace osu.Game.Overlays.BeatmapSet { Vertical = BeatmapSetOverlay.Y_PADDING, Left = WaveOverlayContainer.HORIZONTAL_PADDING, - Right = WaveOverlayContainer.HORIZONTAL_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, + Right = WaveOverlayContainer.HORIZONTAL_PADDING + BeatmapSetOverlay.RIGHT_WIDTH + 10, }, Children = new Drawable[] { @@ -175,14 +178,42 @@ namespace osu.Game.Overlays.BeatmapSet Spacing = new Vector2(10), Children = new Drawable[] { - onlineStatusPill = new BeatmapSetOnlineStatusPill + new FillFlowContainer { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - TextSize = 14, - TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Children = new Drawable[] + { + onlineStatusPill = new BeatmapSetOnlineStatusPill + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + TextSize = 14, + TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } + }, + storyboardIconPill = new StoryboardIconPill + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + IconSize = new Vector2(34), + IconPadding = new MarginPadding(10), + }, + videoIconPill = new VideoIconPill + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + IconSize = new Vector2(34), + IconPadding = new MarginPadding(10), + }, + } }, + Details = new Details(), }, }, @@ -219,6 +250,8 @@ namespace osu.Game.Overlays.BeatmapSet if (setInfo.NewValue == null) { onlineStatusPill.FadeTo(0.5f, 500, Easing.OutQuint); + videoIconPill.Hide(); + storyboardIconPill.Hide(); fadeContent.Hide(); loading.Show(); @@ -236,6 +269,16 @@ namespace osu.Game.Overlays.BeatmapSet loading.Hide(); + if (setInfo.NewValue.HasVideo) + videoIconPill.Show(); + else + videoIconPill.Hide(); + + if (setInfo.NewValue.HasStoryboard) + storyboardIconPill.Show(); + else + storyboardIconPill.Hide(); + var titleText = new RomanisableString(setInfo.NewValue.TitleUnicode, setInfo.NewValue.Title); var artistText = new RomanisableString(setInfo.NewValue.ArtistUnicode, setInfo.NewValue.Artist); diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index cbdb2ea190..215e521d42 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -7,7 +7,6 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; @@ -21,7 +20,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public partial class FavouriteButton : HeaderButton, IHasTooltip + public partial class FavouriteButton : HeaderButton { public readonly Bindable BeatmapSet = new Bindable(); @@ -32,7 +31,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly IBindable localUser = new Bindable(); - public LocalisableString TooltipText + public override LocalisableString TooltipText { get { @@ -75,6 +74,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { favourited.Toggle(); loading.Hide(); + api.LocalUserState.UpdateFavouriteBeatmapSets(); }; request.Failure += e => diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index d21b2546b9..96e6622507 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -2,14 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapListing; @@ -17,29 +18,24 @@ namespace osu.Game.Overlays.BeatmapSet { public partial class Info : Container { - private const float metadata_width = 175; + private const float metadata_width = 185; private const float spacing = 20; - private const float base_height = 220; + private const float base_height = 300; private readonly Box successRateBackground; private readonly Box background; - private readonly SuccessRate successRate; + private readonly MetadataSection userTags; public readonly Bindable BeatmapSet = new Bindable(); - - public APIBeatmap? BeatmapInfo - { - get => successRate.Beatmap; - set => successRate.Beatmap = value; - } + public readonly Bindable Beatmap = new Bindable(); public Info() { + SuccessRate successRate; MetadataSectionNominators nominators; - MetadataSection source, tags; + MetadataSection source, mapperTags; MetadataSectionGenre genre; MetadataSectionLanguage language; - OsuSpriteText notRankedPlaceholder; RelativeSizeAxes = Axes.X; Height = base_height; @@ -66,27 +62,30 @@ namespace osu.Game.Overlays.BeatmapSet Child = new MetadataSectionDescription(), }, }, - new Container + new OsuScrollContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, Width = metadata_width, - Padding = new MarginPadding { Horizontal = 10 }, + Padding = new MarginPadding { Left = 10 }, Margin = new MarginPadding { Right = BeatmapSetOverlay.RIGHT_WIDTH + spacing }, Masking = true, + ScrollbarOverlapsContent = false, Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, + Padding = new MarginPadding { Right = 5 }, Children = new Drawable[] { nominators = new MetadataSectionNominators(), source = new MetadataSectionSource(), genre = new MetadataSectionGenre { Width = 0.5f }, language = new MetadataSectionLanguage { Width = 0.5f }, - tags = new MetadataSectionTags(), + userTags = new MetadataSectionUserTags(), + mapperTags = new MetadataSectionMapperTags(), }, }, }, @@ -107,32 +106,45 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = 20, Horizontal = 15 }, }, - notRankedPlaceholder = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0, - Text = "This beatmap is not ranked", - Font = OsuFont.GetFont(size: 12) - }, }, }, }, }, }; - BeatmapSet.ValueChanged += b => + BeatmapSet.BindValueChanged(b => { nominators.Metadata = (b.NewValue?.CurrentNominations ?? Array.Empty(), b.NewValue?.RelatedUsers ?? Array.Empty()); source.Metadata = b.NewValue?.Source ?? string.Empty; - tags.Metadata = b.NewValue?.Tags ?? string.Empty; + mapperTags.Metadata = b.NewValue?.Tags ?? string.Empty; + updateUserTags(); genre.Metadata = b.NewValue?.Genre ?? new BeatmapSetOnlineGenre { Id = (int)SearchGenre.Unspecified }; language.Metadata = b.NewValue?.Language ?? new BeatmapSetOnlineLanguage { Id = (int)SearchLanguage.Unspecified }; - bool setHasLeaderboard = b.NewValue?.Status > 0; - successRate.Alpha = setHasLeaderboard ? 1 : 0; - notRankedPlaceholder.Alpha = setHasLeaderboard ? 0 : 1; - Height = setHasLeaderboard ? 270 : base_height; - }; + }); + Beatmap.BindValueChanged(b => + { + successRate.Beatmap = b.NewValue; + updateUserTags(); + }); + } + + private void updateUserTags() + { + if (Beatmap.Value?.TopTags == null || Beatmap.Value.TopTags.Length == 0 || BeatmapSet.Value?.RelatedTags == null) + { + userTags.Metadata = null; + return; + } + + var tagsById = BeatmapSet.Value.RelatedTags.ToDictionary(t => t.Id); + userTags.Metadata = Beatmap.Value.TopTags + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null) + // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria + .OrderByDescending(t => t.topTag.VoteCount) + .ThenBy(t => t.relatedTag!.Name) + .Select(t => t.relatedTag!.Name) + .ToArray(); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs index 5cfe4a35b3..12fbc4c790 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs @@ -22,6 +22,7 @@ namespace osu.Game.Overlays.BeatmapSet AddItem(BeatmapLeaderboardScope.Global); AddItem(BeatmapLeaderboardScope.Country); AddItem(BeatmapLeaderboardScope.Friend); + AddItem(BeatmapLeaderboardScope.Team); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionTags.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionMapperTags.cs similarity index 80% rename from osu.Game/Overlays/BeatmapSet/MetadataSectionTags.cs rename to osu.Game/Overlays/BeatmapSet/MetadataSectionMapperTags.cs index fc16ba19d8..47e839a84d 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataSectionTags.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionMapperTags.cs @@ -7,10 +7,10 @@ using osu.Game.Online.Chat; namespace osu.Game.Overlays.BeatmapSet { - public partial class MetadataSectionTags : MetadataSection + public partial class MetadataSectionMapperTags : MetadataSection { - public MetadataSectionTags(Action? searchAction = null) - : base(MetadataType.Tags, searchAction) + public MetadataSectionMapperTags(Action? searchAction = null) + : base(MetadataType.MapperTags, searchAction) { } diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionUserTags.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionUserTags.cs new file mode 100644 index 0000000000..3a9fe8d33f --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionUserTags.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class MetadataSectionUserTags : MetadataSection + { + private readonly Action? searchAction; + + public MetadataSectionUserTags(Action? searchAction = null) + : base(MetadataType.UserTags, null) + { + this.searchAction = searchAction; + } + + protected override void AddMetadata(string[]? tags, LinkFlowContainer loaded) + { + if (tags == null) + return; + + for (int i = 0; i <= tags.Length - 1; i++) + { + string tag = tags[i]; + + if (searchAction != null) + loaded.AddLink(tag, () => searchAction(tag)); + else + loaded.AddLink(tag, LinkAction.SearchBeatmapSet, $@"tag=""""{tag}"""""); + + if (i != tags.Length - 1) + loaded.AddText(" "); + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataType.cs b/osu.Game/Overlays/BeatmapSet/MetadataType.cs index c92cecc17e..fe38d23242 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataType.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataType.cs @@ -8,8 +8,11 @@ namespace osu.Game.Overlays.BeatmapSet { public enum MetadataType { - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoTags))] - Tags, + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoUserTags))] + UserTags, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoMapperTags))] + MapperTags, [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoSource))] Source, diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs index 29a696593d..b161ee49c6 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs @@ -41,6 +41,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores case BeatmapLeaderboardScope.Country: text.Text = BeatmapsetsStrings.ShowScoreboardNoScoresCountry; break; + + case BeatmapLeaderboardScope.Team: + text.Text = BeatmapsetsStrings.ShowScoreboardNoScoresTeam; + break; } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoTeamPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoTeamPlaceholder.cs new file mode 100644 index 0000000000..0bd4a1334f --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoTeamPlaceholder.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osuTK; + +namespace osu.Game.Overlays.BeatmapSet.Scores +{ + public partial class NoTeamPlaceholder : Container + { + public NoTeamPlaceholder() + { + AutoSizeAxes = Axes.Both; + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = LeaderboardStrings.NoTeam, + }, + } + }; + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs index 7cb119bf32..36f71be606 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs @@ -3,11 +3,10 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osuTK; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; +using osuTK; namespace osu.Game.Overlays.BeatmapSet.Scores { @@ -30,7 +29,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = BeatmapsetsStrings.ShowScoreboardSupporterOnly, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), }, text = new LinkFlowContainer(t => t.Font = t.Font.With(size: 11)) { diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index c70c41feed..0c8943ba7d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -160,7 +160,20 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Size = new Vector2(19, 14), }, - username, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new UpdateableTeamFlag(score.User.Team) + { + Size = new Vector2(28, 14), + }, + username, + } + }, #pragma warning disable 618 new StatisticText(score.MaxCombo, score.BeatmapInfo!.MaxCombo, @"0\x"), #pragma warning restore 618 @@ -187,6 +200,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores content.Add(new StatisticText(count, maxCount, @"N0") { Colour = count == 0 ? Color4.Gray : Color4.White }); } + // TODO: all this should be using the same sort of logic as `DrawableProfileScore` is, but that's not easily done + // unless the ENTIRE overlay can be weaned off of `ScoreInfo` and use `SoloScoreInfo` instead if (showPerformancePoints) { if (!score.Ranked) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs index 59ba9cd449..9f7c5c848d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Localisation; using osu.Game.Extensions; using osu.Game.Graphics; @@ -14,7 +15,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { } - protected override string Format() + protected override LocalisableString Format() => Date.ToShortRelativeTime(TimeSpan.FromHours(1)); } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index b53b7826f3..cc06383274 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -40,6 +41,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly LeaderboardModSelector modSelector; private readonly NoScoresPlaceholder noScoresPlaceholder; private readonly NotSupporterPlaceholder notSupporterPlaceholder; + private readonly NoTeamPlaceholder noTeamPlaceholder; [Resolved] private IAPIProvider api { get; set; } @@ -154,10 +156,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores AlwaysPresent = true, Margin = new MarginPadding { Vertical = 10 } }, + noTeamPlaceholder = new NoTeamPlaceholder + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Vertical = 10 }, + Alpha = 0, + }, notSupporterPlaceholder = new NotSupporterPlaceholder { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + Margin = new MarginPadding { Vertical = 10 }, Alpha = 0, }, new FillFlowContainer @@ -239,6 +249,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores getScoresRequest = null; noScoresPlaceholder.Hide(); + noTeamPlaceholder.Hide(); + notSupporterPlaceholder.Hide(); if (Beatmap.Value == null || Beatmap.Value.OnlineID <= 0 || (Beatmap.Value.Status <= BeatmapOnlineStatus.Pending)) { @@ -247,15 +259,20 @@ namespace osu.Game.Overlays.BeatmapSet.Scores return; } - if ((scope.Value != BeatmapLeaderboardScope.Global || modSelector.SelectedMods.Count > 0) && !userIsSupporter) + if ((scope.Value == BeatmapLeaderboardScope.Team) && user.Value.Team == null) + { + Scores = null; + noTeamPlaceholder.Show(); + return; + } + + if (scope.Value.RequiresSupporter(modSelector.SelectedMods.Count > 0) && !userIsSupporter) { Scores = null; notSupporterPlaceholder.Show(); return; } - notSupporterPlaceholder.Hide(); - Show(); loading.Show(); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 13ba9fb74b..14c9bedc67 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -27,7 +27,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly UpdateableAvatar avatar; private readonly LinkFlowContainer usernameText; private readonly DrawableDate achievedOn; + private readonly UpdateableFlag flag; + private readonly UpdateableTeamFlag teamFlag; public TopScoreUserSection() { @@ -112,12 +114,30 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }, } }, - flag = new UpdateableFlag + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(19, 14), - Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + flag = new UpdateableFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(19, 14), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + }, + teamFlag = new UpdateableTeamFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(28, 14), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even + }, + } }, } } @@ -139,6 +159,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { avatar.User = value.User; flag.CountryCode = value.User.CountryCode; + teamFlag.Team = value.User.Team; achievedOn.Date = value.Date; usernameText.Clear(); diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 8de21129d3..255e30038b 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -47,7 +47,10 @@ namespace osu.Game.Overlays Spacing = new Vector2(0, 20), Children = new Drawable[] { - info = new Info(), + info = new Info + { + Beatmap = { BindTarget = Header.HeaderContent.Picker.Beatmap } + }, new ScoresContainer { Beatmap = { BindTarget = Header.HeaderContent.Picker.Beatmap } @@ -60,11 +63,7 @@ namespace osu.Game.Overlays info.BeatmapSet.BindTo(beatmapSet); comments.BeatmapSet.BindTo(beatmapSet); - Header.HeaderContent.Picker.Beatmap.ValueChanged += b => - { - info.BeatmapInfo = b.NewValue; - ScrollFlow.ScrollToStart(); - }; + Header.HeaderContent.Picker.Beatmap.ValueChanged += b => ScrollFlow.ScrollToStart(); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs index 08978ac2ab..fed38c1a1e 100644 --- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -18,7 +16,7 @@ namespace osu.Game.Overlays.Changelog { public partial class ChangelogBuild : FillFlowContainer { - public Action SelectBuild; + public required Action SelectBuild { get; init; } protected readonly APIChangelogBuild Build; @@ -79,7 +77,7 @@ namespace osu.Game.Overlays.Changelog Anchor = Anchor.Centre, Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, - Action = () => SelectBuild?.Invoke(Build), + Action = () => SelectBuild.Invoke(Build), Child = new FillFlowContainer { AutoSizeAxes = Axes.Both, diff --git a/osu.Game/Overlays/Changelog/ChangelogEntry.cs b/osu.Game/Overlays/Changelog/ChangelogEntry.cs index 9c40440778..d6021972c6 100644 --- a/osu.Game/Overlays/Changelog/ChangelogEntry.cs +++ b/osu.Game/Overlays/Changelog/ChangelogEntry.cs @@ -82,7 +82,6 @@ namespace osu.Game.Overlays.Changelog }, title = new LinkFlowContainer { - Direction = FillDirection.Full, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.BottomLeft, diff --git a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs index 13a19de22a..a9ee77ce5d 100644 --- a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Threading; using osu.Framework.Allocation; @@ -26,7 +24,7 @@ namespace osu.Game.Overlays.Changelog { public partial class ChangelogSingleBuild : ChangelogContent { - private APIChangelogBuild build; + private readonly APIChangelogBuild build; public ChangelogSingleBuild(APIChangelogBuild build) { @@ -38,10 +36,12 @@ namespace osu.Game.Overlays.Changelog { bool complete = false; + APIChangelogBuild? onlineBuildDetails = null; + var req = new GetChangelogBuildRequest(build.UpdateStream.Name, build.Version); req.Success += res => { - build = res; + onlineBuildDetails = res; complete = true; }; req.Failure += _ => complete = true; @@ -59,36 +59,35 @@ namespace osu.Game.Overlays.Changelog Thread.Sleep(10); } - if (build != null) + if (onlineBuildDetails == null) return; + + CommentsContainer comments; + + Children = new Drawable[] { - CommentsContainer comments; - - Children = new Drawable[] + new ChangelogBuildWithNavigation(onlineBuildDetails) { SelectBuild = SelectBuild }, + new Box { - new ChangelogBuildWithNavigation(build) { SelectBuild = SelectBuild }, - new Box - { - RelativeSizeAxes = Axes.X, - Height = 2, - 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() - }; + RelativeSizeAxes = Axes.X, + Height = 2, + 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() + }; - comments.ShowComments(CommentableType.Build, build.Id); - } + comments.ShowComments(CommentableType.Build, onlineBuildDetails.Id); } public partial class ChangelogBuildWithNavigation : ChangelogBuild @@ -98,7 +97,7 @@ namespace osu.Game.Overlays.Changelog { } - private OsuSpriteText date; + private OsuSpriteText date = null!; protected override FillFlowContainer CreateHeader() { @@ -144,9 +143,9 @@ namespace osu.Game.Overlays.Changelog private partial class NavigationIconButton : IconButton { - public Action SelectBuild; + public required Action SelectBuild { get; init; } - public NavigationIconButton(APIChangelogBuild build) + public NavigationIconButton(APIChangelogBuild? build) { Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs index 30273d2405..df1ea6c283 100644 --- a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using Humanizer; using osu.Framework.Localisation; using osu.Game.Graphics; @@ -18,14 +16,12 @@ namespace osu.Game.Overlays.Changelog { if (stream.IsFeatured) Width *= 2; + + MainText = Value.DisplayName; + AdditionalText = Value.LatestBuild.DisplayVersion; + InfoText = Value.UserCount > 0 ? $"{"user".ToQuantity(Value.UserCount, "N0")} online" : default(LocalisableString); } - protected override LocalisableString MainText => Value.DisplayName; - - protected override LocalisableString AdditionalText => Value.LatestBuild.DisplayVersion; - - protected override LocalisableString InfoText => Value.UserCount > 0 ? $"{"user".ToQuantity(Value.UserCount, "N0")} online" : null; - protected override Color4 GetBarColour(OsuColour colours) => Value.Colour; } } diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index 4cc38c41e4..dafa14f7e7 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -76,16 +76,18 @@ namespace osu.Game.Overlays Show(); } - public void ShowBuild([NotNull] string updateStream, [NotNull] string version) + public void ShowBuild([NotNull] string version) { - ArgumentNullException.ThrowIfNull(updateStream); ArgumentNullException.ThrowIfNull(version); Show(); performAfterFetch(() => { - var build = builds.Find(b => b.Version == version && b.UpdateStream.Name == updateStream) + string versionPart = version.Split('-')[0]; + string updateStream = version.Split('-')[1]; + + var build = builds.Find(b => b.Version == versionPart && b.UpdateStream.Name == updateStream) ?? Streams.Find(s => s.Name == updateStream)?.LatestBuild; if (build != null) diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs index f027888962..03f6923455 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs @@ -9,6 +9,7 @@ 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.Framework.Localisation; using osu.Framework.Testing; @@ -39,6 +40,7 @@ namespace osu.Game.Overlays.Chat.ChannelList public ChannelGroup AnnounceChannelGroup { get; private set; } = null!; public ChannelGroup PublicChannelGroup { get; private set; } = null!; + public ChannelGroup TeamChannelGroup { get; private set; } = null!; public ChannelGroup PrivateChannelGroup { get; private set; } = null!; private OsuScrollContainer scroll = null!; @@ -79,10 +81,12 @@ namespace osu.Game.Overlays.Chat.ChannelList RelativeSizeAxes = Axes.X, } }, - AnnounceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper(), false), - PublicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper(), false), + // cross-reference for icons: https://github.com/ppy/osu-web/blob/3c9e99eaf4bd9e73d2712f60d67f5bc95f9dfe2b/resources/js/chat/conversation-list.tsx#L13-L19 + AnnounceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper(), FontAwesome.Solid.Bullhorn, false), + PublicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper(), FontAwesome.Solid.Comments, false), selector = new ChannelListItem(ChannelListingChannel), - PrivateChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePM.ToUpper(), true), + TeamChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleTEAM.ToUpper(), FontAwesome.Solid.Users, false), + PrivateChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePM.ToUpper(), FontAwesome.Solid.Envelope, true), }, }, }, @@ -102,6 +106,7 @@ namespace osu.Game.Overlays.Chat.ChannelList }; selector.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); + updateVisibility(); } public void AddChannel(Channel channel) @@ -109,9 +114,13 @@ namespace osu.Game.Overlays.Chat.ChannelList if (channelMap.ContainsKey(channel)) return; - ChannelListItem item = new ChannelListItem(channel); + ChannelListItem item = new ChannelListItem(channel) + { + CanLeave = channel.Type != ChannelType.Team + }; item.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); - item.OnRequestLeave += chan => OnRequestLeave?.Invoke(chan); + if (item.CanLeave) + item.OnRequestLeave += chan => OnRequestLeave?.Invoke(chan); ChannelGroup group = getGroupFromChannel(channel); channelMap.Add(channel, item); @@ -156,6 +165,9 @@ namespace osu.Game.Overlays.Chat.ChannelList case ChannelType.Announce: return AnnounceChannelGroup; + case ChannelType.Team: + return TeamChannelGroup; + default: return PublicChannelGroup; } @@ -163,10 +175,8 @@ namespace osu.Game.Overlays.Chat.ChannelList private void updateVisibility() { - if (AnnounceChannelGroup.ItemFlow.Children.Count == 0) - AnnounceChannelGroup.Hide(); - else - AnnounceChannelGroup.Show(); + AnnounceChannelGroup.Alpha = AnnounceChannelGroup.ItemFlow.Any() ? 1 : 0; + TeamChannelGroup.Alpha = TeamChannelGroup.ItemFlow.Any() ? 1 : 0; } public partial class ChannelGroup : FillFlowContainer @@ -174,7 +184,7 @@ namespace osu.Game.Overlays.Chat.ChannelList private readonly bool sortByRecent; public readonly ChannelListItemFlow ItemFlow; - public ChannelGroup(LocalisableString label, bool sortByRecent) + public ChannelGroup(LocalisableString label, IconUsage icon, bool sortByRecent) { this.sortByRecent = sortByRecent; Direction = FillDirection.Vertical; @@ -184,11 +194,26 @@ namespace osu.Game.Overlays.Chat.ChannelList Children = new Drawable[] { - new OsuSpriteText + new FillFlowContainer { - Text = label, - Margin = new MarginPadding { Left = 18, Bottom = 5 }, - Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = label, + Margin = new MarginPadding { Left = 18, Bottom = 5 }, + Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), + }, + new SpriteIcon + { + Icon = icon, + Size = new Vector2(12), + }, + } }, ItemFlow = new ChannelListItemFlow(sortByRecent) { diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs index b197fe199d..3741852993 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs @@ -18,12 +18,15 @@ using osu.Game.Online.Chat; using osu.Game.Overlays.Chat.Listing; using osu.Game.Users.Drawables; using osuTK; +using osuTK.Input; namespace osu.Game.Overlays.Chat.ChannelList { public partial class ChannelListItem : OsuClickableContainer, IFilterable { public event Action? OnRequestSelect; + + public bool CanLeave { get; init; } = true; public event Action? OnRequestLeave; public readonly Channel Channel; @@ -158,9 +161,20 @@ namespace osu.Game.Overlays.Chat.ChannelList }; } + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Middle) + { + close?.TriggerClick(); + return true; + } + + return base.OnMouseDown(e); + } + private ChannelListItemCloseButton? createCloseButton() { - if (isSelector) + if (isSelector || !CanLeave) return null; return new ChannelListItemCloseButton diff --git a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs index 6d8b21a7c5..b621b555b0 100644 --- a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs +++ b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs @@ -41,13 +41,13 @@ namespace osu.Game.Overlays.Chat #region Scroll handling - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = null) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = null) { base.OnUserScroll(value, animated, distanceDecay); updateTrackState(); } - public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + public new void ScrollTo(double value, bool animated = true, double? distanceDecay = null) { base.ScrollTo(value, animated, distanceDecay); updateTrackState(); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index e386f2ac09..427d874f12 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -46,7 +46,7 @@ namespace osu.Game.Overlays.Chat } } - public IReadOnlyCollection DrawableContentFlow => drawableContentFlow; + public IEnumerable DrawableContentFlow => drawableContentFlow.Children; private const float font_size = 13; @@ -292,7 +292,7 @@ namespace osu.Game.Overlays.Chat // remove non-existent channels from the link list message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chatManager?.AvailableChannels.Any(c => c.Name == link.Argument.ToString()) != true); - isMention = MessageNotifier.CheckContainsUsername(message.DisplayContent, api.LocalUser.Value.Username); + isMention = MessageNotifier.MatchUsername(message.DisplayContent, api.LocalUser.Value.Username).Success; drawableContentFlow.Clear(); drawableContentFlow.AddLinks(message.DisplayContent, message.Links); diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 41098ef823..ad327f4b28 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -7,11 +7,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Cursor; using osu.Game.Online.Chat; using osuTK.Graphics; @@ -48,25 +48,20 @@ namespace osu.Game.Overlays.Chat [BackgroundDependencyLoader] private void load() { - Child = new OsuContextMenuContainer + Child = scroll = new ChannelScrollContainer { + ScrollbarVisible = scrollbarVisible, RelativeSizeAxes = Axes.Both, - Masking = true, - Child = scroll = new ChannelScrollContainer + // Some chat lines have effects that slightly protrude to the bottom, + // which we do not want to mask away, hence the padding. + Padding = new MarginPadding { Bottom = 5 }, + Child = ChatLineFlow = new FillFlowContainer { - ScrollbarVisible = scrollbarVisible, - RelativeSizeAxes = Axes.Both, - // Some chat lines have effects that slightly protrude to the bottom, - // which we do not want to mask away, hence the padding. - Padding = new MarginPadding { Bottom = 5 }, - Child = ChatLineFlow = new FillFlowContainer - { - Padding = new MarginPadding { Left = 3, Right = 10 }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - } - }, + Padding = new MarginPadding { Left = 3, Right = 10 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + } }; newMessagesArrived(Channel.Messages); @@ -116,7 +111,7 @@ namespace osu.Game.Overlays.Chat if (chatLine == null) return; - float center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; + double center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; scroll.ScrollTo(Math.Clamp(center, 0, scroll.ScrollableExtent)); chatLine.Highlight(); @@ -132,6 +127,7 @@ namespace osu.Game.Overlays.Chat Channel.PendingMessageResolved -= pendingMessageResolved; } + [CanBeNull] protected virtual ChatLine CreateChatLine(Message m) => new ChatLine(m); protected virtual DaySeparator CreateDaySeparator(DateTimeOffset time) => new DaySeparator(time); @@ -155,8 +151,13 @@ namespace osu.Game.Overlays.Chat { addDaySeparatorIfRequired(lastMessage, message); - ChatLineFlow.Add(CreateChatLine(message)); - lastMessage = message; + var chatLine = CreateChatLine(message); + + if (chatLine != null) + { + ChatLineFlow.Add(chatLine); + lastMessage = message; + } } var staleMessages = chatLines.Where(c => c.LifetimeEnd == double.MaxValue).ToArray(); diff --git a/osu.Game/Overlays/Chat/DrawableChatUsername.cs b/osu.Game/Overlays/Chat/DrawableChatUsername.cs index 67191f6836..59a4985d08 100644 --- a/osu.Game/Overlays/Chat/DrawableChatUsername.cs +++ b/osu.Game/Overlays/Chat/DrawableChatUsername.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -14,6 +15,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -22,7 +24,11 @@ using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens; +using osu.Game.Screens.Play; +using osu.Game.Users; using osuTK; using osuTK.Graphics; using ChatStrings = osu.Game.Localisation.ChatStrings; @@ -69,6 +75,12 @@ namespace osu.Game.Overlays.Chat [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private MultiplayerClient? multiplayerClient { get; set; } + + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + [Resolved(canBeNull: true)] private ChannelManager? chatManager { get; set; } @@ -81,6 +93,9 @@ namespace osu.Game.Overlays.Chat [Resolved] private Bindable? currentChannel { get; set; } + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + private readonly APIUser user; private readonly OsuSpriteText drawableText; @@ -161,13 +176,10 @@ namespace osu.Game.Overlays.Chat if (user.Equals(APIUser.SYSTEM_USER)) return Array.Empty(); - List items = new List - { - new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, openUserProfile) - }; + if (user.Equals(api.LocalUser.Value)) + return Array.Empty(); - if (!user.Equals(api.LocalUser.Value)) - items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, openUserChannel)); + List items = new List(); if (currentChannel?.Value != null) { @@ -177,8 +189,32 @@ namespace osu.Game.Overlays.Chat })); } - if (!user.Equals(api.LocalUser.Value)) - items.Add(new OsuMenuItem("Report", MenuItemType.Destructive, ReportRequested)); + items.Add(new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, openUserProfile)); + + items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, openUserChannel)); + + // We should probably be checking against an online state here. + // But we can't use MetadataClient.GetPresence because we may not be requesting/receiving presences. + // This isn't really too bad – worst case scenario the client will open spectator view and show the user as "offline". + { + items.Add(new OsuMenuItemSpacer()); + + items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => + { + performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(user))); + })); + + if (multiplayerClient?.Room?.Users.All(u => u.UserID != user.Id) == true) + { + items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => multiplayerClient.InvitePlayer(user.Id))); + } + } + + items.Add(new OsuMenuItemSpacer()); + items.Add(new OsuMenuItem(UsersStrings.ReportButtonText, MenuItemType.Destructive, ReportRequested)); + items.Add(api.LocalUserState.Blocks.Any(b => b.TargetID == user.OnlineID) + ? new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Unblock(user))) + : new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Block(user)))); return items.ToArray(); } diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs index 466f8b2f5d..539d7c5075 100644 --- a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs +++ b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs @@ -31,7 +31,11 @@ namespace osu.Game.Overlays.Chat.Listing public bool FilteringActive { get; set; } public IEnumerable FilterTerms => new LocalisableString[] { Channel.Name, Channel.Topic ?? string.Empty }; - public bool MatchingFilter { set => this.FadeTo(value ? 1f : 0f, 100); } + + public bool MatchingFilter + { + set => this.FadeTo(value ? 1f : 0f, 100); + } protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index c49afa3a66..e7422d6f86 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -19,6 +19,7 @@ using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online; @@ -142,9 +143,13 @@ namespace osu.Game.Overlays new PopoverContainer { RelativeSizeAxes = Axes.Both, - Child = currentChannelContainer = new Container + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, + Child = currentChannelContainer = new Container + { + RelativeSizeAxes = Axes.Both, + } } }, loading = new LoadingLayer(true), @@ -228,7 +233,8 @@ namespace osu.Game.Overlays return true; case PlatformAction.DocumentClose: - channelManager.LeaveChannel(currentChannel.Value); + if (currentChannel.Value?.Type != ChannelType.Team) + channelManager.LeaveChannel(currentChannel.Value); return true; case PlatformAction.TabRestore: diff --git a/osu.Game/Overlays/Comments/CommentReportButton.cs b/osu.Game/Overlays/Comments/CommentReportButton.cs index e4d4d671da..09c0fd32d0 100644 --- a/osu.Game/Overlays/Comments/CommentReportButton.cs +++ b/osu.Game/Overlays/Comments/CommentReportButton.cs @@ -1,13 +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 osu.Framework.Allocation; using osu.Framework.Extensions; 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.Graphics.UserInterface; +using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -19,7 +22,7 @@ using osuTK; namespace osu.Game.Overlays.Comments { - public partial class CommentReportButton : CompositeDrawable, IHasPopover + public partial class CommentReportButton : CompositeDrawable, IHasPopover, IHasLineBaseHeight { private readonly Comment comment; @@ -88,5 +91,7 @@ namespace osu.Game.Overlays.Comments api.Queue(request); } + + public float LineBaseHeight => link.ChildrenOfType().FirstOrDefault()?.LineBaseHeight ?? DrawHeight; } } diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index d664a44be9..805d997998 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -419,8 +419,8 @@ namespace osu.Game.Overlays.Comments private void copyUrl() { - clipboard.SetText($@"{api.APIEndpointUrl}/comments/{Comment.Id}"); - onScreenDisplay?.Display(new CopyUrlToast()); + clipboard.SetText($@"{api.Endpoints.APIUrl}/comments/{Comment.Id}"); + onScreenDisplay?.Display(new CopiedToClipboardToast()); } private void toggleReply() diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs index ee277ff538..02fe681492 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs @@ -1,12 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; -using System.Collections.Specialized; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -18,10 +14,8 @@ using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; -using osu.Game.Online.Spectator; using osu.Game.Resources.Localisation.Web; using osu.Game.Screens; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -36,21 +30,17 @@ namespace osu.Game.Overlays.Dashboard private const float search_textbox_height = 40; private const float padding = 10; - private readonly IBindableList playingUsers = new BindableList(); - private readonly IBindableDictionary onlineUsers = new BindableDictionary(); + private readonly IBindableDictionary onlineUserPresences = new BindableDictionary(); private readonly Dictionary userPanels = new Dictionary(); - private SearchContainer userFlow; - private BasicSearchTextBox searchTextBox; + private SearchContainer userFlow = null!; + private BasicSearchTextBox searchTextBox = null!; [Resolved] - private IAPIProvider api { get; set; } + private MetadataClient metadataClient { get; set; } = null!; [Resolved] - private SpectatorClient spectatorClient { get; set; } - - [Resolved] - private MetadataClient metadataClient { get; set; } + private UserLookupCache users { get; set; } = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -99,18 +89,12 @@ namespace osu.Game.Overlays.Dashboard searchTextBox.Current.ValueChanged += text => userFlow.SearchTerm = text.NewValue; } - [Resolved] - private UserLookupCache users { get; set; } - protected override void LoadComplete() { base.LoadComplete(); - onlineUsers.BindTo(metadataClient.UserStates); - onlineUsers.BindCollectionChanged(onUserUpdated, true); - - playingUsers.BindTo(spectatorClient.PlayingUsers); - playingUsers.BindCollectionChanged(onPlayingUsersChanged, true); + onlineUserPresences.BindTo(metadataClient.UserPresences); + onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true); } protected override void OnFocus(FocusEvent e) @@ -120,7 +104,7 @@ namespace osu.Game.Overlays.Dashboard searchTextBox.TakeFocus(); } - private void onUserUpdated(object sender, NotifyDictionaryChangedEventArgs e) => Schedule(() => + private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs e) => Schedule(() => { switch (e.Action) { @@ -133,42 +117,13 @@ namespace osu.Game.Overlays.Dashboard users.GetUserAsync(userId).ContinueWith(task => { - APIUser user = task.GetResultSafely(); - - if (user == null) - return; - - Schedule(() => - { - // explicitly refetch the user's status. - // things may have changed in between the time of scheduling and the time of actual execution. - if (onlineUsers.TryGetValue(userId, out var updatedStatus)) - { - user.Activity.Value = updatedStatus.Activity; - user.Status.Value = updatedStatus.Status; - } - - userFlow.Add(userPanels[userId] = createUserPanel(user)); - }); + if (task.GetResultSafely() is APIUser user) + Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user))); }); } break; - case NotifyDictionaryChangedAction.Replace: - Debug.Assert(e.NewItems != null); - - foreach (var kvp in e.NewItems) - { - if (userPanels.TryGetValue(kvp.Key, out var panel)) - { - panel.User.Activity.Value = kvp.Value.Activity; - panel.User.Status.Value = kvp.Value.Status; - } - } - - break; - case NotifyDictionaryChangedAction.Remove: Debug.Assert(e.OldItems != null); @@ -183,52 +138,26 @@ namespace osu.Game.Overlays.Dashboard } }); - private void onPlayingUsersChanged(object sender, NotifyCollectionChangedEventArgs e) - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - Debug.Assert(e.NewItems != null); - - foreach (int userId in e.NewItems) - { - if (userPanels.TryGetValue(userId, out var panel)) - panel.CanSpectate.Value = userId != api.LocalUser.Value.Id; - } - - break; - - case NotifyCollectionChangedAction.Remove: - Debug.Assert(e.OldItems != null); - - foreach (int userId in e.OldItems) - { - if (userPanels.TryGetValue(userId, out var panel)) - panel.CanSpectate.Value = false; - } - - break; - } - } - private OnlineUserPanel createUserPanel(APIUser user) => new OnlineUserPanel(user).With(panel => { panel.Anchor = Anchor.TopCentre; panel.Origin = Anchor.TopCentre; - panel.CanSpectate.Value = playingUsers.Contains(user.Id); }); public partial class OnlineUserPanel : CompositeDrawable, IFilterable { public readonly APIUser User; - public BindableBool CanSpectate { get; } = new BindableBool(); + private PurpleRoundedButton spectateButton = null!; public IEnumerable FilterTerms { get; } - [Resolved(canBeNull: true)] - private IPerformFromScreenRunner performer { get; set; } + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + + [Resolved] + private MetadataClient? metadataClient { get; set; } public bool FilteringActive { set; get; } @@ -252,6 +181,28 @@ namespace osu.Game.Overlays.Dashboard AutoSizeAxes = Axes.Both; } + protected override void Update() + { + base.Update(); + + // TODO: we probably don't want to do this every frame. + var activity = metadataClient?.GetPresence(User.Id)?.Activity; + + switch (activity) + { + default: + spectateButton.Enabled.Value = false; + break; + + case UserActivity.InSoloGame: + case UserActivity.InMultiplayerGame: + case UserActivity.InPlaylistGame: + case UserActivity.PlayingDailyChallenge: + spectateButton.Enabled.Value = true; + break; + } + } + [BackgroundDependencyLoader] private void load() { @@ -269,19 +220,15 @@ namespace osu.Game.Overlays.Dashboard { RelativeSizeAxes = Axes.X, Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - // this is SHOCKING - Activity = { BindTarget = User.Activity }, - Status = { BindTarget = User.Status }, + Origin = Anchor.TopCentre }, - new PurpleRoundedButton + spectateButton = new PurpleRoundedButton { RelativeSizeAxes = Axes.X, Text = "Spectate", Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Action = () => performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))), - Enabled = { BindTarget = CanSpectate } } } }, diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 3e393ced01..56cf9fc669 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -1,15 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Generic; using System.Linq; using System.Threading; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -17,48 +12,34 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; -using osu.Game.Users; -using osuTK; namespace osu.Game.Overlays.Dashboard.Friends { public partial class FriendDisplay : CompositeDrawable { - private List users = new List(); - - public List Users - { - get => users; - set - { - users = value; - onlineStreamControl.Populate(value); - } - } - - private CancellationTokenSource cancellationToken; - - [CanBeNull] - private SearchContainer currentContent; - - private FriendOnlineStreamControl onlineStreamControl; - private Box background; - private Box controlBackground; - private UserListToolbar userListToolbar; - private Container itemsPlaceholder; - private LoadingLayer loading; - private BasicSearchTextBox searchTextBox; - private readonly IBindableList apiFriends = new BindableList(); + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private FriendOnlineStreamControl streamControl = null!; + private Box background = null!; + private Box controlBackground = null!; + private UserListToolbar userListToolbar = null!; + private Container listContainer = null!; + private LoadingLayer loading = null!; + private BasicSearchTextBox searchTextBox = null!; + + private CancellationTokenSource? listLoadCancellation; + public FriendDisplay() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; } - [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider colourProvider, IAPIProvider api) + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { InternalChild = new FillFlowContainer { @@ -86,7 +67,7 @@ namespace osu.Game.Overlays.Dashboard.Friends Top = 20, Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - FriendsOnlineStatusItem.PADDING }, - Child = onlineStreamControl = new FriendOnlineStreamControl(), + Child = streamControl = new FriendOnlineStreamControl(), } } }, @@ -157,7 +138,7 @@ namespace osu.Game.Overlays.Dashboard.Friends AutoSizeAxes = Axes.Y, Children = new Drawable[] { - itemsPlaceholder = new Container + listContainer = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -175,127 +156,55 @@ namespace osu.Game.Overlays.Dashboard.Friends background.Colour = colourProvider.Background4; controlBackground.Colour = colourProvider.Background5; - - apiFriends.BindTo(api.Friends); - apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.Select(f => f.TargetUser).ToList()), true); } protected override void LoadComplete() { base.LoadComplete(); - onlineStreamControl.Current.BindValueChanged(_ => recreatePanels()); - userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels()); - userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels()); - searchTextBox.Current.BindValueChanged(_ => - { - if (currentContent.IsNotNull()) - currentContent.SearchTerm = searchTextBox.Current.Value; - }); + apiFriends.BindTo(api.LocalUserState.Friends); + apiFriends.BindCollectionChanged((_, _) => reloadList()); + + userListToolbar.DisplayStyle.BindValueChanged(_ => reloadList(), true); } - private void recreatePanels() + private void reloadList() { - if (!users.Any()) - return; + listLoadCancellation?.Cancel(); + var cancellationSource = listLoadCancellation = new CancellationTokenSource(); - cancellationToken?.Cancel(); - - if (itemsPlaceholder.Any()) - loading.Show(); - - var sortedUsers = sortUsers(getUsersInCurrentGroup()); - - LoadComponentAsync(createTable(sortedUsers), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); - } - - private List getUsersInCurrentGroup() - { - switch (onlineStreamControl.Current.Value?.Status) + FriendsList? currentList = listContainer.SingleOrDefault(); + FriendsList newList = new FriendsList(userListToolbar.DisplayStyle.Value, apiFriends.Select(f => f.TargetUser!).ToArray()) { - default: - case OnlineStatus.All: - return users; - - case OnlineStatus.Offline: - return users.Where(u => !u.IsOnline).ToList(); - - case OnlineStatus.Online: - return users.Where(u => u.IsOnline).ToList(); - } - } - - private void addContentToPlaceholder(SearchContainer content) - { - loading.Hide(); - - var lastContent = currentContent; - - if (lastContent != null) - { - lastContent.FadeOut(100, Easing.OutQuint).Expire(); - lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); - } - - itemsPlaceholder.Add(currentContent = content); - currentContent.FadeIn(200, Easing.OutQuint); - } - - private SearchContainer createTable(List users) - { - var style = userListToolbar.DisplayStyle.Value; - - return new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), - Children = users.Select(u => createUserPanel(u, style)).ToList(), - SearchTerm = searchTextBox.Current.Value, + OnlineStream = { BindTarget = streamControl.Current }, + SortCriteria = { BindTarget = userListToolbar.SortCriteria }, + SearchText = { BindTarget = searchTextBox.Current } }; - } - private UserPanel createUserPanel(APIUser user, OverlayPanelDisplayStyle style) - { - switch (style) + loading.Show(); + LoadComponentAsync(newList, finishLoad, cancellationSource.Token); + + void finishLoad(FriendsList list) { - default: - case OverlayPanelDisplayStyle.Card: - return new UserGridPanel(user).With(panel => - { - panel.Anchor = Anchor.TopCentre; - panel.Origin = Anchor.TopCentre; - panel.Width = 290; - }); + loading.Hide(); - case OverlayPanelDisplayStyle.List: - return new UserListPanel(user); + if (currentList != null) + { + currentList.FadeOut(100, Easing.OutQuint).Expire(); + currentList.Delay(25).Schedule(() => currentList.BypassAutoSizeAxes = Axes.Y); + } - case OverlayPanelDisplayStyle.Brick: - return new UserBrickPanel(user); - } - } - - private List sortUsers(List unsorted) - { - switch (userListToolbar.SortCriteria.Value) - { - default: - case UserSortCriteria.LastVisit: - return unsorted.OrderByDescending(u => u.LastVisit).ToList(); - - case UserSortCriteria.Rank: - return unsorted.OrderByDescending(u => u.Statistics.GlobalRank.HasValue).ThenBy(u => u.Statistics.GlobalRank ?? 0).ToList(); - - case UserSortCriteria.Username: - return unsorted.OrderBy(u => u.Username).ToList(); + listContainer.Add(newList); + newList.FadeIn(200, Easing.OutQuint); } } protected override void Dispose(bool isDisposing) { - cancellationToken?.Cancel(); base.Dispose(isDisposing); + + listLoadCancellation?.Cancel(); + listLoadCancellation?.Dispose(); } } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs index 9f429c23d8..b58b486494 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs @@ -1,30 +1,95 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Generic; -using System.Linq; +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Users; namespace osu.Game.Overlays.Dashboard.Friends { - public partial class FriendOnlineStreamControl : OverlayStreamControl + public partial class FriendOnlineStreamControl : OverlayStreamControl { - protected override OverlayStreamItem CreateStreamItem(FriendStream value) => new FriendsOnlineStatusItem(value); + private readonly IBindableDictionary friendPresences = new BindableDictionary(); + private readonly IBindableList apiFriends = new BindableList(); + private readonly BindableInt countAll = new BindableInt(); + private readonly BindableInt countOnline = new BindableInt(); + private readonly BindableInt countOffline = new BindableInt(); - public void Populate(List users) + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + public FriendOnlineStreamControl() { - Clear(); + Items = + [ + OnlineStatus.All, + OnlineStatus.Online, + OnlineStatus.Offline + ]; + } - int userCount = users.Count; - int onlineUsersCount = users.Count(user => user.IsOnline); + protected override void LoadComplete() + { + base.LoadComplete(); - AddItem(new FriendStream(OnlineStatus.All, userCount)); - AddItem(new FriendStream(OnlineStatus.Online, onlineUsersCount)); - AddItem(new FriendStream(OnlineStatus.Offline, userCount - onlineUsersCount)); + apiFriends.BindTo(api.LocalUserState.Friends); + apiFriends.BindCollectionChanged((_, _) => updateCounts()); - Current.Value = Items.FirstOrDefault(); + friendPresences.BindTo(metadataClient.FriendPresences); + friendPresences.BindCollectionChanged(onFriendPresencesChanged); + + updateCounts(); + } + + private void onFriendPresencesChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + case NotifyDictionaryChangedAction.Remove: + updateCounts(); + break; + } + } + + private void updateCounts() + { + countAll.Value = apiFriends.Count; + countOnline.Value = 0; + countOffline.Value = 0; + + foreach (var user in apiFriends) + { + if (friendPresences.ContainsKey(user.TargetID)) + countOnline.Value++; + else + countOffline.Value++; + } + } + + protected override OverlayStreamItem CreateStreamItem(OnlineStatus value) + { + switch (value) + { + case OnlineStatus.All: + return new FriendsOnlineStatusItem(value) { UserCount = { BindTarget = countAll } }; + + case OnlineStatus.Online: + return new FriendsOnlineStatusItem(value) { UserCount = { BindTarget = countOnline } }; + + case OnlineStatus.Offline: + return new FriendsOnlineStatusItem(value) { UserCount = { BindTarget = countOffline } }; + + default: + throw new ArgumentException(nameof(value)); + } } } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs index 4abece9a8d..f791e34c8f 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendStream.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.Bindables; + namespace osu.Game.Overlays.Dashboard.Friends { public class FriendStream { - public OnlineStatus Status { get; } + public readonly BindableInt UserCount = new BindableInt(); + public readonly OnlineStatus Status; - public int Count { get; } - - public FriendStream(OnlineStatus status, int count) + public FriendStream(OnlineStatus status) { Status = status; - Count = count; } } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs new file mode 100644 index 0000000000..c7689dff8f --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsList.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public partial class FriendsList : CompositeDrawable + { + public readonly IBindable OnlineStream = new Bindable(); + public readonly IBindable SortCriteria = new Bindable(); + public readonly IBindable SearchText = new Bindable(); + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + private readonly IBindableDictionary friendPresences = new BindableDictionary(); + private readonly OverlayPanelDisplayStyle style; + private readonly APIUser[] friends; + + private FriendsSearchContainer searchContainer = null!; + + public FriendsList(OverlayPanelDisplayStyle style, APIUser[] friends) + { + this.style = style; + this.friends = friends; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = searchContainer = new FriendsSearchContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), + SortCriteria = { BindTarget = SortCriteria }, + ChildrenEnumerable = friends.Select(createUserPanel) + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + friendPresences.BindTo(metadataClient.FriendPresences); + friendPresences.BindCollectionChanged(onFriendPresencesChanged); + + SearchText.BindValueChanged(onSearchTextChanged, true); + OnlineStream.BindValueChanged(onFriendsStreamChanged, true); + } + + private void onFriendPresencesChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + case NotifyDictionaryChangedAction.Remove: + updatePanelVisibilities(); + break; + } + } + + private void onSearchTextChanged(ValueChangedEvent search) + { + searchContainer.SearchTerm = search.NewValue; + } + + private void onFriendsStreamChanged(ValueChangedEvent stream) + { + updatePanelVisibilities(); + } + + private void updatePanelVisibilities() + { + foreach (var panel in searchContainer) + { + switch (OnlineStream.Value) + { + case OnlineStatus.All: + panel.CanBeShown.Value = true; + break; + + case OnlineStatus.Online: + panel.CanBeShown.Value = friendPresences.ContainsKey(panel.User.OnlineID); + break; + + case OnlineStatus.Offline: + panel.CanBeShown.Value = !friendPresences.ContainsKey(panel.User.OnlineID); + break; + } + } + } + + private FilterableUserPanel createUserPanel(APIUser user) + { + UserPanel panel; + + switch (style) + { + default: + case OverlayPanelDisplayStyle.Card: + panel = new UserGridPanel(user); + panel.Anchor = Anchor.TopCentre; + panel.Origin = Anchor.TopCentre; + panel.Width = 290; + break; + + case OverlayPanelDisplayStyle.List: + panel = new UserListPanel(user); + break; + + case OverlayPanelDisplayStyle.Brick: + panel = new UserBrickPanel(user); + break; + } + + return new FilterableUserPanel(panel); + } + + private partial class FriendsSearchContainer : SearchContainer + { + public readonly IBindable SortCriteria = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + SortCriteria.BindValueChanged(_ => InvalidateLayout(), true); + } + + public override IEnumerable FlowingChildren + { + get + { + IEnumerable panels = base.FlowingChildren.OfType(); + + switch (SortCriteria.Value) + { + default: + case UserSortCriteria.LastVisit: + // Todo: Last visit time is not currently updated according to realtime user presence. + return panels.OrderByDescending(panel => panel.User.LastVisit); + + case UserSortCriteria.Rank: + // Todo: Statistics are not currently updated according to realtime user statistics, but it's also not currently displayed in the panels. + return panels.OrderByDescending(panel => panel.User.Statistics.GlobalRank.HasValue).ThenBy(panel => panel.User.Statistics.GlobalRank ?? 0); + + case UserSortCriteria.Username: + return panels.OrderBy(panel => panel.User.Username); + } + } + } + } + + public partial class FilterableUserPanel : CompositeDrawable, IConditionalFilterable + { + public readonly Bindable CanBeShown = new Bindable(); + + public APIUser User => panel.User; + + private readonly UserPanel panel; + + public FilterableUserPanel(UserPanel panel) + { + this.panel = panel; + + Anchor = panel.Anchor; + Origin = panel.Origin; + RelativeSizeAxes = panel.RelativeSizeAxes; + AutoSizeAxes = panel.AutoSizeAxes; + + if (!AutoSizeAxes.HasFlagFast(Axes.X)) + Width = panel.Width; + + if (!AutoSizeAxes.HasFlagFast(Axes.Y)) + Height = panel.Height; + + InternalChild = panel; + } + + IBindable IConditionalFilterable.CanBeShown => CanBeShown; + + IEnumerable IHasFilterTerms.FilterTerms => panel.FilterTerms; + + bool IFilterable.MatchingFilter + { + set + { + if (value) + Show(); + else + Hide(); + } + } + + bool IFilterable.FilteringActive + { + set { } + } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs index 2aea631b7c..459592085b 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs @@ -2,27 +2,32 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Localisation; using osu.Game.Graphics; using osuTK.Graphics; namespace osu.Game.Overlays.Dashboard.Friends { - public partial class FriendsOnlineStatusItem : OverlayStreamItem + public partial class FriendsOnlineStatusItem : OverlayStreamItem { - public FriendsOnlineStatusItem(FriendStream value) + public readonly IBindable UserCount = new Bindable(); + + public FriendsOnlineStatusItem(OnlineStatus value) : base(value) { + MainText = value.GetLocalisableDescription(); } - protected override LocalisableString MainText => Value.Status.GetLocalisableDescription(); - - protected override LocalisableString AdditionalText => Value.Count.ToString(); + protected override void LoadComplete() + { + base.LoadComplete(); + UserCount.BindValueChanged(count => AdditionalText = count.NewValue.ToString(), true); + } protected override Color4 GetBarColour(OsuColour colours) { - switch (Value.Status) + switch (Value) { case OnlineStatus.All: return Color4.White; @@ -34,7 +39,7 @@ namespace osu.Game.Overlays.Dashboard.Friends return Color4.Black; default: - throw new ArgumentException($@"{Value.Status} status does not provide a colour in {nameof(GetBarColour)}."); + throw new ArgumentException($@"{Value} status does not provide a colour in {nameof(GetBarColour)}."); } } } diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index 1861f892bd..1912736135 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -6,7 +6,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Online.Metadata; -using osu.Game.Online.Multiplayer; using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard.Friends; @@ -18,6 +17,7 @@ namespace osu.Game.Overlays private MetadataClient metadataClient { get; set; } = null!; private IBindable metadataConnected = null!; + private IDisposable? userPresenceWatchToken; public DashboardOverlay() : base(OverlayColourScheme.Purple) @@ -61,9 +61,12 @@ namespace osu.Game.Overlays return; if (State.Value == Visibility.Visible) - metadataClient.BeginWatchingUserPresence().FireAndForget(); + userPresenceWatchToken ??= metadataClient.BeginWatchingUserPresence(); else - metadataClient.EndWatchingUserPresence().FireAndForget(); + { + userPresenceWatchToken?.Dispose(); + userPresenceWatchToken = null; + } } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index a23c394c9f..0fec1625eb 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -75,7 +75,9 @@ namespace osu.Game.Overlays.Dialog return; bodyText = value; + body.Text = value; + body.TextAnchor = bodyText.ToString().Contains('\n') ? Anchor.TopLeft : Anchor.TopCentre; } } @@ -210,13 +212,12 @@ namespace osu.Game.Overlays.Dialog RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopCentre, - Padding = new MarginPadding { Horizontal = 15 }, + Padding = new MarginPadding { Horizontal = 15, Bottom = 10 }, }, body = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 18)) { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, - TextAnchor = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = 15 }, @@ -301,6 +302,7 @@ namespace osu.Game.Overlays.Dialog { content.ScaleTo(0.7f); ring.ResizeTo(ringMinifiedSize); + icon.ScaleTo(0f); } content @@ -308,6 +310,7 @@ namespace osu.Game.Overlays.Dialog .FadeIn(ENTER_DURATION, Easing.OutQuint); ring.ResizeTo(ringSize, ENTER_DURATION * 1.5f, Easing.OutQuint); + icon.Delay(100).ScaleTo(1, ENTER_DURATION * 1.5f, Easing.OutQuint); } protected override void PopOut() diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index da60951ab6..392b170ad2 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -21,7 +21,7 @@ using Realms; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupBeatmapScreenStrings), nameof(FirstRunSetupBeatmapScreenStrings.Header))] - public partial class ScreenBeatmaps : FirstRunSetupScreen + public partial class ScreenBeatmaps : WizardScreen { private ProgressRoundedButton downloadBundledButton = null!; private ProgressRoundedButton downloadTutorialButton = null!; diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs index d31ce7ea18..00a753f481 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.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. -#nullable disable - using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -20,9 +17,9 @@ using osu.Game.Overlays.Settings.Sections; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupOverlayStrings), nameof(FirstRunSetupOverlayStrings.Behaviour))] - public partial class ScreenBehaviour : FirstRunSetupScreen + public partial class ScreenBehaviour : WizardScreen { - private SearchContainer searchContainer; + private SearchContainer searchContainer = null!; [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -91,13 +88,11 @@ namespace osu.Game.Overlays.FirstRunSetup new GraphicsSection(), new OnlineSection(), new MaintenanceSection(), + new DebugSection() }, SearchTerm = SettingsItem.CLASSIC_DEFAULT_SEARCH_TERM, } }; - - if (DebugUtils.IsDebugBuild) - searchContainer.Add(new DebugSection()); } private void applyClassic() diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs index 5eb38b6e11..5bdcd8e850 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs @@ -31,7 +31,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunOverlayImportFromStableScreenStrings), nameof(FirstRunOverlayImportFromStableScreenStrings.Header))] - public partial class ScreenImportFromStable : FirstRunSetupScreen + public partial class ScreenImportFromStable : WizardScreen { private static readonly Vector2 button_size = new Vector2(400, 50); diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index d0eefa55c5..edadc333c8 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -25,14 +25,14 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.UIScaling))] - public partial class ScreenUIScale : FirstRunSetupScreen + public partial class ScreenUIScale : WizardScreen { [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -101,11 +101,14 @@ namespace osu.Game.Overlays.FirstRunSetup } } - private partial class NestedSongSelect : PlaySongSelect + private partial class NestedSongSelect : SoloSongSelect { - protected override bool ControlGlobalMusic => false; - public override bool? ApplyModTrackAdjustments => false; + + public NestedSongSelect() + { + ControlGlobalMusic = false; + } } private partial class UIScaleSlider : RoundedSliderBar @@ -145,16 +148,17 @@ namespace osu.Game.Overlays.FirstRunSetup protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new DependencyContainer(new DependencyIsolationContainer(base.CreateChildDependencies(parent))); + private ScreenFooter footer; + [BackgroundDependencyLoader] private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) { - Beatmap.Value = new DummyWorkingBeatmap(audio, textures); + Beatmap.Default = Beatmap.Value = new DummyWorkingBeatmap(audio, textures); Ruleset.Value = rulesets.AvailableRulesets.First(); OsuScreenStack stack; OsuLogo logo; - ScreenFooter footer; Padding = new MarginPadding(5); @@ -192,6 +196,13 @@ namespace osu.Game.Overlays.FirstRunSetup // intentionally load synchronously so it is included in the initial load of the first run screen. stack.PushSynchronously(screen); } + + protected override void LoadComplete() + { + base.LoadComplete(); + + footer.Show(); + } } private class DependencyIsolationContainer : IReadOnlyDependencyContainer diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs index 68c6c78986..e03a08dd46 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs @@ -23,7 +23,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupOverlayStrings), nameof(FirstRunSetupOverlayStrings.WelcomeTitle))] - public partial class ScreenWelcome : FirstRunSetupScreen + public partial class ScreenWelcome : WizardScreen { [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) @@ -65,6 +65,8 @@ namespace osu.Game.Overlays.FirstRunSetup }; } + public override LocalisableString? NextStepText => FirstRunSetupOverlayStrings.GetStarted; + private partial class LanguageSelectionFlow : FillFlowContainer { private Bindable language = null!; diff --git a/osu.Game/Overlays/FirstRunSetupOverlay.cs b/osu.Game/Overlays/FirstRunSetupOverlay.cs index 1a302cf51d..6f4fb65f8c 100644 --- a/osu.Game/Overlays/FirstRunSetupOverlay.cs +++ b/osu.Game/Overlays/FirstRunSetupOverlay.cs @@ -1,38 +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.Diagnostics; 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.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Framework.Screens; -using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Database; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Overlays.FirstRunSetup; -using osu.Game.Overlays.Mods; using osu.Game.Overlays.Notifications; using osu.Game.Screens; -using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; namespace osu.Game.Overlays { [Cached] - public partial class FirstRunSetupOverlay : ShearedOverlayContainer + public partial class FirstRunSetupOverlay : WizardOverlay { [Resolved] private IPerformFromScreenRunner performer { get; set; } = null!; @@ -43,97 +26,25 @@ namespace osu.Game.Overlays [Resolved] private OsuConfigManager config { get; set; } = null!; - private ScreenStack? stack; - - public ShearedButton? NextButton => DisplayedFooterContent?.NextButton; - private readonly Bindable showFirstRunSetup = new Bindable(); - private int? currentStepIndex; - - /// - /// The currently displayed screen, if any. - /// - public FirstRunSetupScreen? CurrentScreen => (FirstRunSetupScreen?)stack?.CurrentScreen; - - private readonly List steps = new List(); - - private Container screenContent = null!; - - private Container content = null!; - - private LoadingSpinner loading = null!; - private ScheduledDelegate? loadingShowDelegate; - public FirstRunSetupOverlay() : base(OverlayColourScheme.Purple) { } [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, LegacyImportManager? legacyImportManager) + private void load(LegacyImportManager? legacyImportManager) { - steps.Add(typeof(ScreenWelcome)); - steps.Add(typeof(ScreenUIScale)); - steps.Add(typeof(ScreenBeatmaps)); + AddStep(); + AddStep(); + AddStep(); if (legacyImportManager?.SupportsImportFromStable == true) - steps.Add(typeof(ScreenImportFromStable)); - steps.Add(typeof(ScreenBehaviour)); + AddStep(); + AddStep(); Header.Title = FirstRunSetupOverlayStrings.FirstRunSetupTitle; Header.Description = FirstRunSetupOverlayStrings.FirstRunSetupDescription; - - MainAreaContent.AddRange(new Drawable[] - { - content = new PopoverContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = 20 }, - Child = new GridContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(minSize: 640, maxSize: 800), - new Dimension(), - }, - Content = new[] - { - new[] - { - Empty(), - new InputBlockingContainer - { - Masking = true, - CornerRadius = 14, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background6, - }, - loading = new LoadingSpinner(), - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Vertical = 20 }, - Child = screenContent = new Container { RelativeSizeAxes = Axes.Both, }, - }, - }, - }, - Empty(), - }, - } - } - }, - }); } protected override void LoadComplete() @@ -145,55 +56,6 @@ namespace osu.Game.Overlays if (showFirstRunSetup.Value) Show(); } - [Resolved] - private ScreenFooter footer { get; set; } = null!; - - public new FirstRunSetupFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as FirstRunSetupFooterContent; - - public override VisibilityContainer CreateFooterContent() - { - var footerContent = new FirstRunSetupFooterContent - { - ShowNextStep = showNextStep, - }; - - footerContent.OnLoadComplete += _ => updateButtons(); - return footerContent; - } - - public override bool OnBackButton() - { - if (currentStepIndex == 0) - return false; - - Debug.Assert(stack != null); - - stack.CurrentScreen.Exit(); - currentStepIndex--; - - updateButtons(); - return true; - } - - public override bool OnPressed(KeyBindingPressEvent e) - { - if (!e.Repeat) - { - switch (e.Action) - { - case GlobalAction.Select: - DisplayedFooterContent?.NextButton.TriggerClick(); - return true; - - case GlobalAction.Back: - footer.BackButton.TriggerClick(); - return false; - } - } - - return base.OnPressed(e); - } - public override void Show() { // if we are valid for display, only do so after reaching the main menu. @@ -207,24 +69,11 @@ namespace osu.Game.Overlays }, new[] { typeof(MainMenu) }); } - protected override void PopIn() - { - base.PopIn(); - - content.ScaleTo(0.99f) - .ScaleTo(1, 400, Easing.OutQuint); - - if (currentStepIndex == null) - showFirstStep(); - } - protected override void PopOut() { base.PopOut(); - content.ScaleTo(0.99f, 400, Easing.OutQuint); - - if (currentStepIndex != null) + if (CurrentStepIndex != null) { notificationOverlay.Post(new SimpleNotification { @@ -237,112 +86,14 @@ namespace osu.Game.Overlays }, }); } - else - { - stack?.FadeOut(100) - .Expire(); - } } - private void showFirstStep() + protected override void ShowNextStep() { - Debug.Assert(currentStepIndex == null); + base.ShowNextStep(); - screenContent.Child = stack = new ScreenStack - { - RelativeSizeAxes = Axes.Both, - }; - - currentStepIndex = -1; - showNextStep(); - } - - private void showNextStep() - { - Debug.Assert(currentStepIndex != null); - Debug.Assert(stack != null); - - currentStepIndex++; - - if (currentStepIndex < steps.Count) - { - var nextScreen = (Screen)Activator.CreateInstance(steps[currentStepIndex.Value])!; - - loadingShowDelegate = Scheduler.AddDelayed(() => loading.Show(), 200); - nextScreen.OnLoadComplete += _ => - { - loadingShowDelegate?.Cancel(); - loading.Hide(); - }; - - stack.Push(nextScreen); - } - else - { + if (CurrentStepIndex == null) showFirstRunSetup.Value = false; - currentStepIndex = null; - Hide(); - } - - updateButtons(); - } - - private void updateButtons() => DisplayedFooterContent?.UpdateButtons(currentStepIndex, steps); - - public partial class FirstRunSetupFooterContent : VisibilityContainer - { - public ShearedButton NextButton { get; private set; } = null!; - - public Action? ShowNextStep; - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - RelativeSizeAxes = Axes.Both; - - InternalChild = NextButton = new ShearedButton(0) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Right = 12f }, - RelativeSizeAxes = Axes.X, - Width = 1, - Text = FirstRunSetupOverlayStrings.GetStarted, - DarkerColour = colourProvider.Colour2, - LighterColour = colourProvider.Colour1, - Action = () => ShowNextStep?.Invoke(), - }; - } - - public void UpdateButtons(int? currentStep, IReadOnlyList steps) - { - NextButton.Enabled.Value = currentStep != null; - - if (currentStep == null) - return; - - bool isFirstStep = currentStep == 0; - bool isLastStep = currentStep == steps.Count - 1; - - if (isFirstStep) - NextButton.Text = FirstRunSetupOverlayStrings.GetStarted; - else - { - NextButton.Text = isLastStep - ? CommonStrings.Finish - : LocalisableString.Interpolate($@"{CommonStrings.Next} ({steps[currentStep.Value + 1].GetLocalisableDescription()})"); - } - } - - protected override void PopIn() - { - this.FadeIn(); - } - - protected override void PopOut() - { - this.Delay(400).FadeOut(); - } } } } diff --git a/osu.Game/Overlays/HoldToConfirmOverlay.cs b/osu.Game/Overlays/HoldToConfirmOverlay.cs index ac8b4ad0a8..6cdbc6450d 100644 --- a/osu.Game/Overlays/HoldToConfirmOverlay.cs +++ b/osu.Game/Overlays/HoldToConfirmOverlay.cs @@ -58,11 +58,13 @@ namespace osu.Game.Overlays }; audio.Tracks.AddAdjustment(AdjustableProperty.Volume, audioVolume); + audio.Samples.AddAdjustment(AdjustableProperty.Volume, audioVolume); } protected override void Dispose(bool isDisposing) { audio?.Tracks.RemoveAdjustment(AdjustableProperty.Volume, audioVolume); + audio?.Samples.RemoveAdjustment(AdjustableProperty.Volume, audioVolume); base.Dispose(isDisposing); } } diff --git a/osu.Game/Overlays/INotificationOverlay.cs b/osu.Game/Overlays/INotificationOverlay.cs index 19c646a714..10103cea4d 100644 --- a/osu.Game/Overlays/INotificationOverlay.cs +++ b/osu.Game/Overlays/INotificationOverlay.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays /// /// Whether there are any ongoing operations, such as imports or downloads. /// - public bool HasOngoingOperations => OngoingOperations.Any(); + bool HasOngoingOperations => OngoingOperations.Any(); /// /// All current displayed notifications, whether in the toast tray or a section. @@ -44,6 +44,6 @@ namespace osu.Game.Overlays /// /// All ongoing operations (ie. any not in a completed or cancelled state). /// - public IEnumerable OngoingOperations => AllNotifications.OfType().Where(p => p.Ongoing); + IEnumerable OngoingOperations => AllNotifications.OfType().Where(p => p.Ongoing); } } diff --git a/osu.Game/Overlays/KudosuTable.cs b/osu.Game/Overlays/KudosuTable.cs index 93884435a4..d6eaf586b9 100644 --- a/osu.Game/Overlays/KudosuTable.cs +++ b/osu.Game/Overlays/KudosuTable.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays protected override CountryCode GetCountryCode(APIUser item) => item.CountryCode; - protected override Drawable CreateFlagContent(APIUser item) + protected override Drawable[] CreateFlagContent(APIUser item) { var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { @@ -89,7 +89,7 @@ namespace osu.Game.Overlays TextAnchor = Anchor.CentreLeft }; username.AddUserLink(item); - return username; + return [username]; } } } diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index 13e528ff8f..215a946b42 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Graphics; @@ -63,6 +64,7 @@ namespace osu.Game.Overlays.Login }, username = new OsuTextBox { + InputProperties = new TextInputProperties(TextInputType.Username, false), PlaceholderText = UsersStrings.LoginUsername.ToLower(), RelativeSizeAxes = Axes.X, Text = api.ProvidedUsername, @@ -127,7 +129,7 @@ namespace osu.Game.Overlays.Login } }; - forgottenPasswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.WebsiteRootUrl}/home/password-reset"); + forgottenPasswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.Endpoints.WebsiteUrl}/home/password-reset"); password.OnCommit += (_, _) => performLogin(); diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index 84bd0c36b9..6d74fc442e 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -9,13 +9,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Settings; using osu.Game.Users; using osuTK; @@ -38,14 +38,15 @@ namespace osu.Game.Overlays.Login /// public Action? RequestHide; - private IBindable user = null!; - private readonly Bindable status = new Bindable(); - private readonly IBindable apiState = new Bindable(); + private readonly Bindable configUserStatus = new Bindable(); [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private OsuConfigManager config { get; set; } = null!; + public override RectangleF BoundingBox => bounding ? base.BoundingBox : RectangleF.Empty; public bool Bounding @@ -68,17 +69,11 @@ namespace osu.Game.Overlays.Login { base.LoadComplete(); + config.BindWith(OsuSetting.UserOnlineStatus, configUserStatus); + configUserStatus.BindValueChanged(e => updateDropdownCurrent(e.NewValue), true); + apiState.BindTo(api.State); apiState.BindValueChanged(onlineStateChanged, true); - - user = api.LocalUser.GetBoundCopy(); - user.BindValueChanged(u => - { - status.UnbindBindings(); - status.BindTo(u.NewValue.Status); - }, true); - - status.BindValueChanged(e => updateDropdownCurrent(e.NewValue), true); } private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => @@ -157,23 +152,23 @@ namespace osu.Game.Overlays.Login }, }; - updateDropdownCurrent(status.Value); + updateDropdownCurrent(configUserStatus.Value); dropdown.Current.BindValueChanged(action => { switch (action.NewValue) { case UserAction.Online: - api.LocalUser.Value.Status.Value = UserStatus.Online; + configUserStatus.Value = UserStatus.Online; dropdown.StatusColour = colours.Green; break; case UserAction.DoNotDisturb: - api.LocalUser.Value.Status.Value = UserStatus.DoNotDisturb; + configUserStatus.Value = UserStatus.DoNotDisturb; dropdown.StatusColour = colours.Red; break; case UserAction.AppearOffline: - api.LocalUser.Value.Status.Value = UserStatus.Offline; + configUserStatus.Value = UserStatus.Offline; dropdown.StatusColour = colours.Gray7; break; diff --git a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs index 77835b1f09..2cdc4bf6a6 100644 --- a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs +++ b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Graphics; @@ -12,6 +13,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Settings; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -20,11 +22,11 @@ namespace osu.Game.Overlays.Login { public partial class SecondFactorAuthForm : Container { - private OsuTextBox codeTextBox = null!; - private LinkFlowContainer explainText = null!; private ErrorTextFlowContainer errorText = null!; private LoadingLayer loading = null!; + private FillFlowContainer contentFlow = null!; + private OsuTextBox codeTextBox = null!; [Resolved] private IAPIProvider api { get; set; } = null!; @@ -35,6 +37,8 @@ namespace osu.Game.Overlays.Login RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }; + Children = new Drawable[] { new FillFlowContainer @@ -45,45 +49,18 @@ namespace osu.Game.Overlays.Login Spacing = new Vector2(0, SettingsSection.ITEM_SPACING), Children = new Drawable[] { - new FillFlowContainer + contentFlow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, Direction = FillDirection.Vertical, Spacing = new Vector2(0f, SettingsSection.ITEM_SPACING), - Children = new Drawable[] - { - new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Text = "An email has been sent to you with a verification code. Enter the code.", - }, - codeTextBox = new OsuTextBox - { - PlaceholderText = "Enter code", - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - }, - explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - errorText = new ErrorTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - }, - }, }, - new LinkFlowContainer + errorText = new ErrorTextFlowContainer { - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Alpha = 0, }, } }, @@ -93,10 +70,60 @@ namespace osu.Game.Overlays.Login } }; + if (api.LastLoginError?.Message is string error) + { + errorText.Alpha = 1; + errorText.AddErrors(new[] { error }); + } + + showContent(api.SessionVerificationMethod!.Value); + } + + private void showContent(SessionVerificationMethod sessionVerificationMethod) + { + switch (sessionVerificationMethod) + { + case SessionVerificationMethod.EmailMessage: + showEmailVerification(); + break; + + case SessionVerificationMethod.TimedOneTimePassword: + showTotpVerification(); + break; + } + } + + private void showEmailVerification() + { + LinkFlowContainer explainText; + + contentFlow.Clear(); + contentFlow.AddRange(new Drawable[] + { + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = "An email has been sent to you with a verification code. Enter the code.", + }, + codeTextBox = new OsuTextBox + { + InputProperties = new TextInputProperties(TextInputType.Code), + PlaceholderText = "Enter code", + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, + explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }); + explainText.AddParagraph(UserVerificationStrings.BoxInfoCheckSpam); // We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something). explainText.AddParagraph("If you can't access your email or have forgotten what you used, please follow the "); - explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.WebsiteRootUrl}/home/password-reset"); + explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.Endpoints.WebsiteUrl}/home/password-reset"); explainText.AddText(". You can also "); explainText.AddLink(UserVerificationStrings.BoxInfoReissueLink, () => { @@ -121,18 +148,66 @@ namespace osu.Game.Overlays.Login codeTextBox.Current.BindValueChanged(code => { - if (code.NewValue.Length == 8) + string trimmedCode = code.NewValue.Trim(); + + if (trimmedCode.Length == 8) { - api.AuthenticateSecondFactor(code.NewValue); + api.AuthenticateSecondFactor(trimmedCode); codeTextBox.Current.Disabled = true; } }); + } - if (api.LastLoginError?.Message is string error) + private void showTotpVerification() + { + LinkFlowContainer explainText; + + contentFlow.Clear(); + contentFlow.AddRange(new Drawable[] { - errorText.Alpha = 1; - errorText.AddErrors(new[] { error }); - } + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = "Please enter the code from your authenticator app.", + }, + codeTextBox = new OsuNumberBox + { + InputProperties = new TextInputProperties(TextInputType.NumericalPassword), + PlaceholderText = "Enter code", + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, + explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }); + + // We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something). + explainText.AddParagraph("If you can't access your app, "); + explainText.AddLink("you can verify using email instead", () => + { + var fallbackRequest = new VerificationMailFallbackRequest(); + fallbackRequest.Success += showEmailVerification; + fallbackRequest.Failure += ex => errorText.Text = ex.Message; + Task.Run(() => api.Perform(fallbackRequest)); + }); + explainText.AddText(". You can also "); + explainText.AddLink(UserVerificationStrings.BoxInfoLogoutLink, () => { api.Logout(); }); + explainText.AddText("."); + + codeTextBox.Current.BindValueChanged(code => + { + string trimmedCode = code.NewValue.Trim(); + + if (trimmedCode.Length == 6) + { + api.AuthenticateSecondFactor(trimmedCode); + codeTextBox.Current.Disabled = true; + } + }); } public override bool AcceptsFocus => true; diff --git a/osu.Game/Overlays/MarqueeContainer.cs b/osu.Game/Overlays/MarqueeContainer.cs new file mode 100644 index 0000000000..2d651abb00 --- /dev/null +++ b/osu.Game/Overlays/MarqueeContainer.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Overlays +{ + public partial class MarqueeContainer : CompositeDrawable + { + /// + /// Whether the marquee should be allowed to scroll the content if it overflows. + /// Note that upon changing the value of this, any existing scrolls will be terminated instantly. + /// + public bool AllowScrolling + { + get => allowScrolling; + set + { + allowScrolling = value; + scrollCached.Invalidate(); + } + } + + private bool allowScrolling = true; + + /// + /// Time in milliseconds before scrolling begins. + /// + public double InitialMoveDelay { get; set; } = 1000; + + /// + /// The to anchor the content to if it does not overflow. + /// + public Anchor NonOverflowingContentAnchor { get; init; } = Anchor.TopLeft; + + public Func? CreateContent + { + set + { + createContent = value; + if (IsLoaded) + updateContent(); + } + } + + private Func? createContent; + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + public float OverflowSpacing { get; init; } = 15; + + private const float pixels_per_second = 50; + + private Drawable mainContent = null!; + private Drawable fillerContent = null!; + private FillFlowContainer flow = null!; + + private readonly Cached scrollCached = new Cached(); + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + public MarqueeContainer() + { + AddLayout(drawSizeLayout); + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = flow = new MarqueeFlow + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = NonOverflowingContentAnchor, + Origin = NonOverflowingContentAnchor, + Spacing = new Vector2(OverflowSpacing), + OnRequiredParentSizeInvalidated = () => scrollCached.Invalidate(), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateContent(); + } + + private void updateContent() + { + flow.Clear(); + + if (createContent == null) + return; + + flow.Add(mainContent = createContent()); + flow.Add(fillerContent = createContent().With(d => d.Alpha = 0)); + scrollCached.Invalidate(); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (scrollCached.IsValid && drawSizeLayout.IsValid) + return; + + float overflowWidth = mainContent.DrawWidth - DrawWidth; + + if (overflowWidth > 0 && AllowScrolling) + { + fillerContent.Alpha = 1; + flow.Anchor = Anchor.TopLeft; + flow.Origin = Anchor.TopLeft; + + float targetX = mainContent.DrawWidth + OverflowSpacing; + + flow.MoveToX(0) + .Delay(InitialMoveDelay) + .MoveToX(-targetX, targetX * 1000 / pixels_per_second) + .Loop(); + } + else + { + fillerContent.Alpha = 0; + flow.ClearTransforms(); + flow.MoveToX(0, 300, Easing.OutQuint); + flow.Anchor = NonOverflowingContentAnchor; + flow.Origin = NonOverflowingContentAnchor; + } + + scrollCached.Validate(); + drawSizeLayout.Validate(); + } + + private partial class MarqueeFlow : FillFlowContainer + { + public required Action OnRequiredParentSizeInvalidated { get; init; } + + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + if (invalidation.HasFlag(Invalidation.RequiredParentSizeToFit)) + OnRequiredParentSizeInvalidated.Invoke(); + + return base.OnInvalidate(invalidation, source); + } + } + } +} diff --git a/osu.Game/Overlays/MedalAnimation.cs b/osu.Game/Overlays/MedalAnimation.cs index daceeedf47..fdca0b2cc7 100644 --- a/osu.Game/Overlays/MedalAnimation.cs +++ b/osu.Game/Overlays/MedalAnimation.cs @@ -245,18 +245,19 @@ namespace osu.Game.Overlays this.FadeOut(200); } - public void Dismiss() + public bool Dismiss() { if (drawableMedal != null && drawableMedal.State != DisplayState.Full) { // if we haven't yet, play out the animation fully drawableMedal.State = DisplayState.Full; FinishTransforms(true); - return; + return false; } Hide(); Expire(); + return true; } private partial class BackgroundStrip : Container diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 19f61cb910..25e22ffbda 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -35,7 +34,7 @@ namespace osu.Game.Overlays private IAPIProvider api { get; set; } = null!; private Container medalContainer = null!; - private MedalAnimation? lastAnimation; + private MedalAnimation? currentMedalDisplay; [BackgroundDependencyLoader] private void load() @@ -54,11 +53,12 @@ namespace osu.Game.Overlays { base.LoadComplete(); - OverlayActivationMode.BindValueChanged(val => - { - if (val.NewValue == OverlayActivation.All && (queuedMedals.Any() || medalContainer.Any() || lastAnimation?.IsLoaded == false)) - Show(); - }, true); + OverlayActivationMode.BindValueChanged(_ => showNextMedal(), true); + } + + public override void Hide() + { + // don't allow hiding the overlay via any method other than our own. } private void handleMedalMessages(SocketMessage obj) @@ -83,34 +83,18 @@ namespace osu.Game.Overlays var medalAnimation = new MedalAnimation(medal); - queuedMedals.Enqueue(medalAnimation); Logger.Log($"Queueing medal unlock for \"{medal.Name}\" ({queuedMedals.Count} to display)"); - if (OverlayActivationMode.Value == OverlayActivation.All) - Scheduler.AddOnce(Show); - } - - protected override void Update() - { - base.Update(); - - if (medalContainer.Any() || lastAnimation?.IsLoaded == false) - return; - - if (!queuedMedals.TryDequeue(out lastAnimation)) + Schedule(() => LoadComponentAsync(medalAnimation, m => { - Logger.Log("All queued medals have been displayed!"); - Hide(); - return; - } - - Logger.Log($"Preparing to display \"{lastAnimation.Medal.Name}\""); - LoadComponentAsync(lastAnimation, medalContainer.Add); + queuedMedals.Enqueue(m); + showNextMedal(); + })); } protected override bool OnClick(ClickEvent e) { - lastAnimation?.Dismiss(); + progressDisplayByUser(); return true; } @@ -118,19 +102,54 @@ namespace osu.Game.Overlays { if (e.Action == GlobalAction.Back) { - lastAnimation?.Dismiss(); + progressDisplayByUser(); return true; } return base.OnPressed(e); } + private void progressDisplayByUser() + { + // Dismissing may sometimes play out the medal animation rather than immediately dismissing. + if (currentMedalDisplay?.Dismiss() == false) + return; + + currentMedalDisplay = null; + showNextMedal(); + } + + private void showNextMedal() + { + // If already displayed, keep displaying medals regardless of activation mode changes. + if (OverlayActivationMode.Value != OverlayActivation.All && State.Value == Visibility.Hidden) + return; + + // A medal is already displaying. + if (currentMedalDisplay != null) + return; + + if (queuedMedals.TryDequeue(out currentMedalDisplay)) + { + Logger.Log($"Displaying \"{currentMedalDisplay.Medal.Name}\""); + medalContainer.Add(currentMedalDisplay); + Show(); + } + else if (State.Value == Visibility.Visible) + { + Logger.Log("All queued medals have been displayed, hiding overlay!"); + base.Hide(); + } + } + protected override void Dispose(bool isDisposing) { - base.Dispose(isDisposing); - + // this event subscription fires async loads, which hard-fail if `CompositeDrawable.disposalCancellationSource` is canceled, which happens in the base call. + // therefore, unsubscribe from this event early to reduce the chances of a stray event firing at an inconvenient spot. if (api.IsNotNull()) api.NotificationsClient.MessageReceived -= handleMedalMessages; + + base.Dispose(isDisposing); } } } diff --git a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs index 2beed6645a..6b7ffbd1db 100644 --- a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs +++ b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs @@ -107,12 +107,7 @@ namespace osu.Game.Overlays.MedalSplash }, }; - description.AddText(medal.Description, s => - { - s.Anchor = Anchor.TopCentre; - s.Origin = Anchor.TopCentre; - s.Font = s.Font.With(size: 16); - }); + description.AddText(medal.Description, s => s.Font = s.Font.With(size: 16)); medalContainer.OnLoadComplete += _ => { diff --git a/osu.Game/Overlays/Mods/AddPresetButton.cs b/osu.Game/Overlays/Mods/AddPresetButton.cs index 276afd9bec..e4f7f83c11 100644 --- a/osu.Game/Overlays/Mods/AddPresetButton.cs +++ b/osu.Game/Overlays/Mods/AddPresetButton.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Mods Height = ModSelectPanel.HEIGHT; // shear will be applied at a higher level in `ModPresetColumn`. - Content.Shear = Vector2.Zero; + Shear = Vector2.Zero; Padding = new MarginPadding(); Text = "+"; diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index 7df7d6339c..817a61f7ac 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -40,11 +40,13 @@ namespace osu.Game.Overlays.Mods public AddPresetPopover(AddPresetButton addPresetButton) { + const float content_width = 300; + button = addPresetButton; Child = new FillFlowContainer { - Width = 300, + Width = content_width, AutoSizeAxes = Axes.Y, Spacing = new Vector2(7), Children = new Drawable[] @@ -63,12 +65,24 @@ namespace osu.Game.Overlays.Mods Label = CommonStrings.Description, TabbableContentContainer = this }, - createButton = new ShearedButton + new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = ModSelectOverlayStrings.AddPreset, - Action = createPreset + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(7), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + createButton = new ShearedButton(content_width) + { + // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Text = ModSelectOverlayStrings.AddPreset, + Action = createPreset + } + } } } }; diff --git a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs deleted file mode 100644 index 957ee23e3b..0000000000 --- a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Utils; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK; - -namespace osu.Game.Overlays.Mods -{ - public partial class AdjustedAttributesTooltip : VisibilityContainer, ITooltip - { - private FillFlowContainer attributesFillFlow = null!; - - private Container content = null!; - - private Data? data; - - [Resolved] - private OsuColour colours { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load() - { - AutoSizeAxes = Axes.Both; - - Masking = true; - CornerRadius = 5; - - InternalChildren = new Drawable[] - { - content = new Container - { - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray3, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Vertical = 10, Horizontal = 15 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = "One or more values are being adjusted by mods that change speed.", - }, - attributesFillFlow = new FillFlowContainer - { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both - } - } - } - } - }, - }; - - updateDisplay(); - } - - private void updateDisplay() - { - attributesFillFlow.Clear(); - - if (data != null) - { - attemptAdd("CS", bd => bd.CircleSize); - attemptAdd("HP", bd => bd.DrainRate); - attemptAdd("OD", bd => bd.OverallDifficulty); - attemptAdd("AR", bd => bd.ApproachRate); - } - - if (attributesFillFlow.Any()) - content.Show(); - else - content.Hide(); - - void attemptAdd(string name, Func lookup) - { - double originalValue = lookup(data.OriginalDifficulty); - double adjustedValue = lookup(data.AdjustedDifficulty); - - if (!Precision.AlmostEquals(originalValue, adjustedValue)) - attributesFillFlow.Add(new AttributeDisplay(name, originalValue, adjustedValue)); - } - } - - public void SetContent(Data? data) - { - if (this.data == data) - return; - - this.data = data; - updateDisplay(); - } - - protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); - protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); - - public void Move(Vector2 pos) => Position = pos; - - public class Data - { - public BeatmapDifficulty OriginalDifficulty { get; } - public BeatmapDifficulty AdjustedDifficulty { get; } - - public Data(BeatmapDifficulty originalDifficulty, BeatmapDifficulty adjustedDifficulty) - { - OriginalDifficulty = originalDifficulty; - AdjustedDifficulty = adjustedDifficulty; - } - } - - private partial class AttributeDisplay : CompositeDrawable - { - public AttributeDisplay(string name, double original, double adjusted) - { - AutoSizeAxes = Axes.Both; - - InternalChild = new OsuSpriteText - { - Font = OsuFont.Default.With(weight: FontWeight.Bold), - Text = $"{name}: {original:0.0#} → {adjusted:0.0#}" - }; - } - } - } -} diff --git a/osu.Game/Overlays/Mods/BeatmapAttributeTooltip.cs b/osu.Game/Overlays/Mods/BeatmapAttributeTooltip.cs new file mode 100644 index 0000000000..5bd4ed3dbc --- /dev/null +++ b/osu.Game/Overlays/Mods/BeatmapAttributeTooltip.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Difficulty; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class BeatmapAttributeTooltip : VisibilityContainer, ITooltip + { + private readonly OverlayColourProvider? colourProvider; + + private Container content = null!; + + private RulesetBeatmapAttribute? attribute; + private OsuSpriteText adjustedByModsText = null!; + private OsuTextFlowContainer descriptionText = null!; + private GridContainer metricsGrid = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public BeatmapAttributeTooltip(OverlayColourProvider? colourProvider = null) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + content = new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background4 ?? colours.Gray3, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 10, Horizontal = 15 }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + descriptionText = new OsuTextFlowContainer + { + AutoSizeAxes = Axes.Both, + MaximumSize = new Vector2(380, 0), + }, + metricsGrid = new GridContainer + { + AutoSizeAxes = Axes.Both, + ColumnDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(minSize: 10), + new Dimension(GridSizeMode.AutoSize), + ] + }, + adjustedByModsText = new OsuSpriteText + { + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + }, + } + }, + } + }, + }; + + updateDisplay(); + } + + private void updateDisplay() + { + bool shouldShow = false; + + if (attribute != null) + { + descriptionText.Text = attribute.Description ?? default; + shouldShow = attribute.Description != null; + + metricsGrid.Content = attribute.AdditionalMetrics.Select(metric => new[] + { + new OsuSpriteText + { + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Text = metric.Name, + Colour = metric.Colour ?? colourProvider?.Content2 ?? Colour4.White, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + Empty(), + new OsuSpriteText + { + Font = OsuFont.Style.Caption1, + Text = metric.Value, + Colour = Interpolation.ValueAt(0.85f, colourProvider?.Content1 ?? Colour4.White, metric.Colour ?? colourProvider?.Content1 ?? Colour4.White, 0, 1), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } + }).ToArray(); + metricsGrid.RowDimensions = Enumerable.Repeat(new Dimension(GridSizeMode.AutoSize), attribute.AdditionalMetrics.Length).ToArray(); + metricsGrid.Alpha = attribute.AdditionalMetrics.Length > 0 ? 1 : 0; + shouldShow |= attribute.AdditionalMetrics.Length > 0; + + if (!Precision.AlmostEquals(attribute.OriginalValue, attribute.AdjustedValue)) + { + adjustedByModsText.Text = $"This value is being adjusted by mods ({attribute.OriginalValue:0.0#} → {attribute.AdjustedValue:0.0#})."; + adjustedByModsText.Alpha = 1; + shouldShow = true; + } + else + adjustedByModsText.Alpha = 0; + } + + if (shouldShow) + content.Show(); + else + content.Hide(); + } + + public void SetContent(RulesetBeatmapAttribute? attribute) + { + if (this.attribute == attribute) + return; + + this.attribute = attribute; + updateDisplay(); + } + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + public void Move(Vector2 pos) => Position = pos; + } +} diff --git a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs index 2670c20d26..e4ca354ffd 100644 --- a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs +++ b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs @@ -8,7 +8,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; @@ -20,7 +19,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Utils; -using osuTK; namespace osu.Game.Overlays.Mods { @@ -28,16 +26,11 @@ namespace osu.Game.Overlays.Mods /// On the mod select overlay, this provides a local updating view of BPM, star rating and other /// difficulty attributes so the user can have a better insight into what mods are changing. /// - public partial class BeatmapAttributesDisplay : ModFooterInformationDisplay, IHasCustomTooltip + public partial class BeatmapAttributesDisplay : ModFooterInformationDisplay { private StarRatingDisplay starRatingDisplay = null!; private BPMDisplay bpmDisplay = null!; - private VerticalAttributeDisplay circleSizeDisplay = null!; - private VerticalAttributeDisplay drainRateDisplay = null!; - private VerticalAttributeDisplay approachRateDisplay = null!; - private VerticalAttributeDisplay overallDifficultyDisplay = null!; - public Bindable BeatmapInfo { get; } = new Bindable(); public Bindable> Mods { get; } = new Bindable>(); @@ -55,45 +48,32 @@ namespace osu.Game.Overlays.Mods protected IBindable GameRuleset = null!; private CancellationTokenSource? cancellationSource; - private IBindable starDifficulty = null!; - - public ITooltip GetCustomTooltip() => new AdjustedAttributesTooltip(); - - public AdjustedAttributesTooltip.Data? TooltipContent { get; private set; } + private IBindable starDifficulty = null!; private const float transition_duration = 250; [BackgroundDependencyLoader] private void load() { - const float shear = OsuGame.SHEAR; - LeftContent.AddRange(new Drawable[] { starRatingDisplay = new StarRatingDisplay(default, animated: true) { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Shear = new Vector2(-shear, 0), + Shear = -OsuGame.SHEAR, }, bpmDisplay = new BPMDisplay { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Shear = new Vector2(-shear, 0), + Shear = -OsuGame.SHEAR, AutoSizeAxes = Axes.Y, Width = 75, } }); RightContent.Alpha = 0; - RightContent.AddRange(new Drawable[] - { - circleSizeDisplay = new VerticalAttributeDisplay("CS") { Shear = new Vector2(-shear, 0), }, - drainRateDisplay = new VerticalAttributeDisplay("HP") { Shear = new Vector2(-shear, 0), }, - overallDifficultyDisplay = new VerticalAttributeDisplay("OD") { Shear = new Vector2(-shear, 0), }, - approachRateDisplay = new VerticalAttributeDisplay("AR") { Shear = new Vector2(-shear, 0), }, - }); } protected override void LoadComplete() @@ -137,7 +117,7 @@ namespace osu.Game.Overlays.Mods starDifficulty = difficultyCache.GetBindableDifficulty(BeatmapInfo.Value, (cancellationSource = new CancellationTokenSource()).Token); starDifficulty.BindValueChanged(s => { - starRatingDisplay.Current.Value = s.NewValue ?? default; + starRatingDisplay.Current.Value = s.NewValue; if (!starRatingDisplay.IsPresent) starRatingDisplay.FinishTransforms(true); @@ -176,23 +156,24 @@ namespace osu.Game.Overlays.Mods bpmDisplay.Current.Value = FormatUtils.RoundBPM(BeatmapInfo.Value.BPM, rate); - BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(BeatmapInfo.Value.Difficulty); - - foreach (var mod in Mods.Value.OfType()) - mod.ApplyToDifficulty(originalDifficulty); - Ruleset ruleset = GameRuleset.Value.CreateInstance(); - BeatmapDifficulty adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); + var displayAttributes = ruleset.GetBeatmapAttributesForDisplay(BeatmapInfo.Value, Mods.Value).ToList(); - TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, adjustedDifficulty); + // if there are not enough attribute displays, make more + for (int i = RightContent.Count; i < displayAttributes.Count; i++) + RightContent.Add(new VerticalAttributeDisplay { Shear = -OsuGame.SHEAR }); - approachRateDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.ApproachRate, adjustedDifficulty.ApproachRate); - overallDifficultyDisplay.AdjustType.Value = VerticalAttributeDisplay.CalculateEffect(originalDifficulty.OverallDifficulty, adjustedDifficulty.OverallDifficulty); + // populate all attribute displays that need to be visible... + for (int i = 0; i < displayAttributes.Count; i++) + { + var attribute = displayAttributes[i]; + var display = (VerticalAttributeDisplay)RightContent[i]; + display.SetAttribute(attribute); + } - circleSizeDisplay.Current.Value = adjustedDifficulty.CircleSize; - drainRateDisplay.Current.Value = adjustedDifficulty.DrainRate; - approachRateDisplay.Current.Value = adjustedDifficulty.ApproachRate; - overallDifficultyDisplay.Current.Value = adjustedDifficulty.OverallDifficulty; + // and hide any extra ones + for (int i = displayAttributes.Count; i < RightContent.Count; i++) + ((VerticalAttributeDisplay)RightContent[i]).SetAttribute(null); }); private void updateCollapsedState() diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs index 526ab6fc63..eb128c7792 100644 --- a/osu.Game/Overlays/Mods/EditPresetPopover.cs +++ b/osu.Game/Overlays/Mods/EditPresetPopover.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.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Database; using osu.Game.Graphics; @@ -51,9 +52,11 @@ namespace osu.Game.Overlays.Mods [BackgroundDependencyLoader] private void load() { + const float content_width = 300; + Child = new FillFlowContainer { - Width = 300, + Width = content_width, AutoSizeAxes = Axes.Y, Spacing = new Vector2(7), Direction = FillDirection.Vertical, @@ -75,42 +78,58 @@ namespace osu.Game.Overlays.Mods TabbableContentContainer = this, Current = { Value = preset.PerformRead(p => p.Description) }, }, - new OsuScrollContainer + new Container { RelativeSizeAxes = Axes.X, Height = 100, - Padding = new MarginPadding(7), - Child = scrollContent = new FillFlowContainer + CornerRadius = 10, + Masking = true, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(7), - Spacing = new Vector2(7), + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(7), + Child = scrollContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(7), + Spacing = new Vector2(7), + } + }, } }, new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, + AutoSizeAxes = Axes.Both, Spacing = new Vector2(7), + Direction = FillDirection.Vertical, Children = new Drawable[] { - useCurrentModsButton = new ShearedButton + useCurrentModsButton = new ShearedButton(content_width) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, Text = ModSelectOverlayStrings.UseCurrentMods, DarkerColour = colours.Blue1, LighterColour = colours.Blue0, TextColour = colourProvider.Background6, Action = useCurrentMods, }, - saveButton = new ShearedButton + saveButton = new ShearedButton(content_width) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, Text = Resources.Localisation.Web.CommonStrings.ButtonsSave, DarkerColour = colours.Orange1, LighterColour = colours.Orange0, diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 326394a207..7d2ce54074 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -106,7 +106,7 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.CentreLeft, Scale = new Vector2(0.8f), RelativeSizeAxes = Axes.X, - Shear = new Vector2(-OsuGame.SHEAR, 0) + Shear = -OsuGame.SHEAR }); ItemsFlow.Padding = new MarginPadding { diff --git a/osu.Game/Overlays/Mods/ModCustomisationPanel.cs b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs index 03a1b3d0dd..e6d73fe092 100644 --- a/osu.Game/Overlays/Mods/ModCustomisationPanel.cs +++ b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs @@ -223,15 +223,28 @@ namespace osu.Game.Overlays.Mods inputManager = GetContainingInputManager()!; } + private double timeUntilCollapse; + + private const double collapse_grace_time = 180; + private const float collapse_grace_position = 40; + protected override void Update() { base.Update(); - if (ExpandedState.Value == ModCustomisationPanelState.Expanded - && !ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position) - && inputManager.DraggedDrawable == null) + if (ExpandedState.Value == ModCustomisationPanelState.Expanded) { - ExpandedState.Value = ModCustomisationPanelState.Collapsed; + bool canCollapse = !DrawRectangle.Inflate(new Vector2(collapse_grace_position)).Contains(ToLocalSpace(inputManager.CurrentState.Mouse.Position)) + && inputManager.DraggedDrawable == null; + + if (canCollapse) + { + if (timeUntilCollapse <= 0) + ExpandedState.Value = ModCustomisationPanelState.Collapsed; + timeUntilCollapse -= Time.Elapsed; + } + else + timeUntilCollapse = collapse_grace_time; } } } diff --git a/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs b/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs index 6665a3b8dc..db42200775 100644 --- a/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs +++ b/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs @@ -37,7 +37,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.BottomRight, AutoSizeAxes = Axes.X, Height = ShearedButton.DEFAULT_HEIGHT, - Shear = new Vector2(OsuGame.SHEAR, 0), + Shear = OsuGame.SHEAR, CornerRadius = ShearedButton.CORNER_RADIUS, BorderThickness = ShearedButton.BORDER_THICKNESS, Masking = true, diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index b85904f22b..df72692f48 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -37,7 +37,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.Centre, Origin = Anchor.Centre, Active = { BindTarget = Active }, - Shear = new Vector2(-OsuGame.SHEAR, 0), + Shear = -OsuGame.SHEAR, Scale = new Vector2(HEIGHT / ModSwitchSmall.DEFAULT_SIZE) }; } diff --git a/osu.Game/Overlays/Mods/ModPresetRow.cs b/osu.Game/Overlays/Mods/ModPresetRow.cs index 4829e93b87..408c541bf5 100644 --- a/osu.Game/Overlays/Mods/ModPresetRow.cs +++ b/osu.Game/Overlays/Mods/ModPresetRow.cs @@ -1,10 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -14,12 +15,20 @@ namespace osu.Game.Overlays.Mods { public partial class ModPresetRow : FillFlowContainer { + private readonly Mod mod; + public ModPresetRow(Mod mod) + { + this.mod = mod; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; - Spacing = new Vector2(4); + Spacing = new Vector2(5); InternalChildren = new Drawable[] { new FillFlowContainer @@ -39,26 +48,47 @@ namespace osu.Game.Overlays.Mods }, new OsuSpriteText { - Text = mod.Name, - Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), - Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Bottom = 2 } - } + Origin = Anchor.CentreLeft, + Font = OsuFont.Torus.With(size: 16f, weight: FontWeight.SemiBold), + Colour = colourProvider.Content1, + UseFullGlyphHeight = false, + Text = mod.Name, + }, } - } - }; - - if (!string.IsNullOrEmpty(mod.SettingDescription)) - { - AddInternal(new OsuTextFlowContainer + }, + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 14 }, - Text = mod.SettingDescription - }); - } + Padding = new MarginPadding { Horizontal = 10f }, + Alpha = mod.SettingDescription.Any() ? 1 : 0, + Children = new Drawable[] + { + new TextFlowContainer(t => + { + t.Font = OsuFont.Torus.With(size: 12f, weight: FontWeight.SemiBold); + }) + { + AutoSizeAxes = Axes.Both, + Colour = colourProvider.Content2, + Text = string.Join('\n', mod.SettingDescription.Select(svp => svp.setting)), + }, + new TextFlowContainer(t => + { + t.Font = OsuFont.Torus.With(size: 12f, weight: FontWeight.SemiBold); + }) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Colour = colourProvider.Content1, + TextAnchor = Anchor.TopRight, + Text = string.Join('\n', mod.SettingDescription.Select(svp => svp.value)), + }, + } + } + }; } } } diff --git a/osu.Game/Overlays/Mods/ModPresetTooltip.cs b/osu.Game/Overlays/Mods/ModPresetTooltip.cs index 6ffcfca1e0..4464ba22f1 100644 --- a/osu.Game/Overlays/Mods/ModPresetTooltip.cs +++ b/osu.Game/Overlays/Mods/ModPresetTooltip.cs @@ -2,6 +2,7 @@ // 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.Cursor; @@ -14,6 +15,9 @@ namespace osu.Game.Overlays.Mods { public partial class ModPresetTooltip : VisibilityContainer, ITooltip { + [Cached] + private readonly OverlayColourProvider colourProvider; + protected override Container Content { get; } private const double transition_duration = 200; @@ -22,6 +26,8 @@ namespace osu.Game.Overlays.Mods public ModPresetTooltip(OverlayColourProvider colourProvider) { + this.colourProvider = colourProvider; + Width = 250; AutoSizeAxes = Axes.Y; @@ -39,7 +45,7 @@ namespace osu.Game.Overlays.Mods { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 10, Right = 10, Top = 5, Bottom = 5 }, + Padding = new MarginPadding(10f), Spacing = new Vector2(7), Children = new[] { @@ -51,6 +57,7 @@ namespace osu.Game.Overlays.Mods { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = 5f }, } } } @@ -64,7 +71,13 @@ namespace osu.Game.Overlays.Mods if (ReferenceEquals(preset, lastPreset)) return; - descriptionText.Text = preset.Description; + if (!string.IsNullOrEmpty(preset.Description)) + { + descriptionText.Show(); + descriptionText.Text = preset.Description; + } + else + descriptionText.Hide(); lastPreset = preset; diff --git a/osu.Game/Overlays/Mods/ModSelectColumn.cs b/osu.Game/Overlays/Mods/ModSelectColumn.cs index 8a499a391c..92c75e3494 100644 --- a/osu.Game/Overlays/Mods/ModSelectColumn.cs +++ b/osu.Game/Overlays/Mods/ModSelectColumn.cs @@ -70,7 +70,7 @@ namespace osu.Game.Overlays.Mods { Width = WIDTH; RelativeSizeAxes = Axes.Y; - Shear = new Vector2(OsuGame.SHEAR, 0); + Shear = OsuGame.SHEAR; InternalChildren = new Drawable[] { @@ -96,7 +96,7 @@ namespace osu.Game.Overlays.Mods { RelativeSizeAxes = Axes.X, Height = header_height, - Shear = new Vector2(-OsuGame.SHEAR, 0), + Shear = -OsuGame.SHEAR, Velocity = 0.7f, ClampAxes = Axes.Y }, @@ -111,7 +111,7 @@ namespace osu.Game.Overlays.Mods AutoSizeAxes = Axes.Y, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Shear = new Vector2(-OsuGame.SHEAR, 0), + Shear = -OsuGame.SHEAR, Padding = new MarginPadding { Horizontal = 17, diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index ed73340eeb..9ba3b3774f 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -35,7 +35,7 @@ using osuTK.Input; namespace osu.Game.Overlays.Mods { - public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler + public partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler { public const int BUTTON_WIDTH = 200; @@ -96,7 +96,7 @@ namespace osu.Game.Overlays.Mods /// /// Whether the column with available mod presets should be shown. /// - protected virtual bool ShowPresets => false; + public bool ShowPresets { get; init; } protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false); @@ -125,7 +125,7 @@ namespace osu.Game.Overlays.Mods [Resolved] private ScreenFooter? footer { get; set; } - protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) + public ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) { } @@ -168,7 +168,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Direction = FillDirection.Horizontal, - Shear = new Vector2(OsuGame.SHEAR, 0), + Shear = OsuGame.SHEAR, RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Margin = new MarginPadding { Horizontal = 70 }, @@ -353,7 +353,10 @@ namespace osu.Game.Overlays.Mods .ToArray(); foreach (var modState in modStates) + { + modState.Active.Value = SelectedMods.Value.Any(selected => selected.GetType() == modState.Mod.GetType()); modState.Active.BindValueChanged(_ => updateFromInternalSelection()); + } newLocalAvailableMods[modType] = modStates; } @@ -710,20 +713,20 @@ namespace osu.Game.Overlays.Mods // the bounds below represent the horizontal range of scroll items to be considered fully visible/active, in the scroll's internal coordinate space. // note that clamping is applied to the left scroll bound to ensure scrolling past extents does not change the set of active columns. - float leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); - float rightVisibleBound = leftVisibleBound + DrawWidth; + double leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); + double rightVisibleBound = leftVisibleBound + DrawWidth; // if a movement is occurring at this time, the bounds below represent the full range of columns that the scroll movement will encompass. // this will be used to ensure that columns do not change state from active to inactive back and forth until they are fully scrolled past. - float leftMovementBound = Math.Min(Current, Target); - float rightMovementBound = Math.Max(Current, Target) + DrawWidth; + double leftMovementBound = Math.Min(Current, Target); + double rightMovementBound = Math.Max(Current, Target) + DrawWidth; foreach (var column in Child) { // DrawWidth/DrawPosition do not include shear effects, and we want to know the full extents of the columns post-shear, // so we have to manually compensate. var topLeft = column.ToSpaceOfOtherDrawable(Vector2.Zero, ScrollContent); - var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * OsuGame.SHEAR, 0), ScrollContent); + var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * OsuGame.SHEAR.X, 0), ScrollContent); bool isCurrentlyVisible = Precision.AlmostBigger(topLeft.X, leftVisibleBound) && Precision.DefinitelyBigger(rightVisibleBound, bottomRight.X); diff --git a/osu.Game/Overlays/Mods/ModSelectPanel.cs b/osu.Game/Overlays/Mods/ModSelectPanel.cs index 284356f37e..5cf858fc1d 100644 --- a/osu.Game/Overlays/Mods/ModSelectPanel.cs +++ b/osu.Game/Overlays/Mods/ModSelectPanel.cs @@ -20,7 +20,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -87,7 +86,7 @@ namespace osu.Game.Overlays.Mods Content.CornerRadius = CORNER_RADIUS; Content.BorderThickness = 2; - Shear = new Vector2(OsuGame.SHEAR, 0); + Shear = OsuGame.SHEAR; Children = new Drawable[] { @@ -128,10 +127,10 @@ namespace osu.Game.Overlays.Mods { Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - Shear = new Vector2(-OsuGame.SHEAR, 0), + Shear = -OsuGame.SHEAR, Margin = new MarginPadding { - Left = -18 * OsuGame.SHEAR + Left = -18 * OsuGame.SHEAR.X }, ShowTooltip = false, // Tooltip is handled by `IncompatibilityDisplayingModPanel`. }, @@ -139,7 +138,7 @@ namespace osu.Game.Overlays.Mods { Font = OsuFont.Default.With(size: 12), RelativeSizeAxes = Axes.X, - Shear = new Vector2(-OsuGame.SHEAR, 0), + Shear = -OsuGame.SHEAR, ShowTooltip = false, // Tooltip is handled by `IncompatibilityDisplayingModPanel`. } } @@ -301,7 +300,10 @@ namespace osu.Game.Overlays.Mods } } - public bool FilteringActive { set { } } + public bool FilteringActive + { + set { } + } #endregion } diff --git a/osu.Game/Overlays/Mods/RankingInformationDisplay.cs b/osu.Game/Overlays/Mods/RankingInformationDisplay.cs index 75a8f289d8..11c963f616 100644 --- a/osu.Game/Overlays/Mods/RankingInformationDisplay.cs +++ b/osu.Game/Overlays/Mods/RankingInformationDisplay.cs @@ -15,7 +15,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Utils; -using osuTK; namespace osu.Game.Overlays.Mods { @@ -52,7 +51,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, RelativeSizeAxes = Axes.Both, - Shear = new Vector2(OsuGame.SHEAR, 0), + Shear = OsuGame.SHEAR, CornerRadius = ShearedButton.CORNER_RADIUS, Masking = true, Children = new Drawable[] @@ -79,7 +78,7 @@ namespace osu.Game.Overlays.Mods { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Shear = new Vector2(-OsuGame.SHEAR, 0), + Shear = -OsuGame.SHEAR, Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) } } @@ -94,7 +93,7 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.Centre, Child = counter = new EffectCounter { - Shear = new Vector2(-OsuGame.SHEAR, 0), + Shear = -OsuGame.SHEAR, Anchor = Anchor.Centre, Origin = Anchor.Centre, Current = { BindTarget = ModMultiplier } diff --git a/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs b/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs index a3e24b486f..977da67e31 100644 --- a/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs +++ b/osu.Game/Overlays/Mods/VerticalAttributeDisplay.cs @@ -7,33 +7,30 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osuTK.Graphics; namespace osu.Game.Overlays.Mods { - public partial class VerticalAttributeDisplay : Container, IHasCurrentValue + public partial class VerticalAttributeDisplay : Container, IHasCustomTooltip { - public Bindable Current - { - get => current.Current; - set => current.Current = value; - } - private readonly BindableWithCurrent current = new BindableWithCurrent(); - public Bindable AdjustType = new Bindable(); - /// /// Text to display in the top area of the display. /// - public LocalisableString Label { get; protected set; } + public LocalisableString Label + { + get => text.Text; + set => text.Text = value; + } private readonly EffectCounter counter; private readonly OsuSpriteText text; @@ -41,11 +38,70 @@ namespace osu.Game.Overlays.Mods [Resolved] private OsuColour colours { get; set; } = null!; - private void updateTextColor() + public VerticalAttributeDisplay() + { + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + + Origin = Anchor.CentreLeft; + Anchor = Anchor.CentreLeft; + + InternalChild = new FillFlowContainer + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Y, + Width = 42, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + text = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Font = OsuFont.Default.With(size: 20, weight: FontWeight.Bold) + }, + counter = new EffectCounter + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Current = { BindTarget = current }, + } + } + }; + } + + public void SetAttribute(RulesetBeatmapAttribute? attribute) + { + if (attribute != null) + { + text.Text = attribute.Acronym; + current.Value = attribute.AdjustedValue; + var effect = calculateEffect(attribute.OriginalValue, attribute.AdjustedValue); + updateTextColor(effect); + Alpha = 1; + } + else + Alpha = 0; + + TooltipContent = attribute; + } + + private static ModEffect calculateEffect(double oldValue, double newValue) + { + if (Precision.AlmostEquals(newValue, oldValue, 0.01)) + return ModEffect.NotChanged; + if (newValue < oldValue) + return ModEffect.DifficultyReduction; + + return ModEffect.DifficultyIncrease; + } + + private void updateTextColor(ModEffect effect) { Color4 newColor; - switch (AdjustType.Value) + switch (effect) { case ModEffect.NotChanged: newColor = Color4.White; @@ -60,61 +116,13 @@ namespace osu.Game.Overlays.Mods break; default: - throw new ArgumentOutOfRangeException(nameof(AdjustType.Value)); + throw new ArgumentOutOfRangeException(nameof(effect), effect, null); } text.Colour = newColor; counter.Colour = newColor; } - public VerticalAttributeDisplay(LocalisableString label) - { - Label = label; - - AutoSizeAxes = Axes.X; - - Origin = Anchor.CentreLeft; - Anchor = Anchor.CentreLeft; - - AdjustType.BindValueChanged(_ => updateTextColor()); - - InternalChild = new FillFlowContainer - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - AutoSizeAxes = Axes.Y, - Width = 50, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - text = new OsuSpriteText - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Text = Label, - Margin = new MarginPadding { Horizontal = 15 }, // to reserve space for 0.XX value - Font = OsuFont.Default.With(size: 20, weight: FontWeight.Bold) - }, - counter = new EffectCounter - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Current = { BindTarget = Current }, - } - } - }; - } - - public static ModEffect CalculateEffect(double oldValue, double newValue) - { - if (Precision.AlmostEquals(newValue, oldValue, 0.01)) - return ModEffect.NotChanged; - if (newValue < oldValue) - return ModEffect.DifficultyReduction; - - return ModEffect.DifficultyIncrease; - } - public enum ModEffect { NotChanged, @@ -133,5 +141,8 @@ namespace osu.Game.Overlays.Mods Font = OsuFont.Default.With(size: 18, weight: FontWeight.SemiBold) }; } + + public ITooltip GetCustomTooltip() => new BeatmapAttributeTooltip(); + public RulesetBeatmapAttribute? TooltipContent { get; set; } } } diff --git a/osu.Game/Overlays/Music/FilterControl.cs b/osu.Game/Overlays/Music/FilterControl.cs deleted file mode 100644 index a61702645a..0000000000 --- a/osu.Game/Overlays/Music/FilterControl.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osuTK; -using System; -using osu.Framework.Allocation; - -namespace osu.Game.Overlays.Music -{ - public partial class FilterControl : Container - { - public Action FilterChanged; - - public readonly FilterTextBox Search; - private readonly NowPlayingCollectionDropdown collectionDropdown; - - public FilterControl() - { - Children = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0f, 10f), - Children = new Drawable[] - { - Search = new FilterTextBox - { - RelativeSizeAxes = Axes.X, - Height = 40, - }, - collectionDropdown = new NowPlayingCollectionDropdown { RelativeSizeAxes = Axes.X } - }, - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Search.Current.BindValueChanged(_ => updateCriteria()); - collectionDropdown.Current.BindValueChanged(_ => updateCriteria(), true); - } - - private void updateCriteria() => FilterChanged?.Invoke(createCriteria()); - - private FilterCriteria createCriteria() => new FilterCriteria - { - SearchText = Search.Current.Value, - Collection = collectionDropdown.Current.Value?.Collection - }; - - public partial class FilterTextBox : BasicSearchTextBox - { - protected override bool AllowCommit => true; - - [BackgroundDependencyLoader] - private void load() - { - Masking = true; - CornerRadius = 5; - - BackgroundUnfocused = OsuColour.Gray(0.06f); - BackgroundFocused = OsuColour.Gray(0.12f); - } - } - } -} diff --git a/osu.Game/Overlays/Music/FilterCriteria.cs b/osu.Game/Overlays/Music/FilterCriteria.cs deleted file mode 100644 index ad491be845..0000000000 --- a/osu.Game/Overlays/Music/FilterCriteria.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. - -#nullable disable - -using JetBrains.Annotations; -using osu.Game.Collections; -using osu.Game.Database; - -namespace osu.Game.Overlays.Music -{ - public class FilterCriteria - { - /// - /// The search text. - /// - public string SearchText; - - /// - /// The collection to filter beatmaps from. - /// - [CanBeNull] - public Live Collection; - } -} diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index 78de76b981..8cec85b748 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -9,7 +9,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; -using osu.Game.Configuration; +using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Overlays.OSD; @@ -92,9 +92,9 @@ namespace osu.Game.Overlays.Music } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(RealmKeyBindingStore keyBindingStore) { - ShortcutText.Text = config.LookupKeyBindings(action).ToUpper(); + ShortcutText.Text = keyBindingStore.GetBindingsStringFor(action).ToUpper(); } } } diff --git a/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs index 0f2e9400d9..2ba222b976 100644 --- a/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs +++ b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs @@ -15,7 +15,7 @@ namespace osu.Game.Overlays.Music /// /// A for use in the . /// - public partial class NowPlayingCollectionDropdown : CollectionDropdown + public partial class NowPlayingCollectionDropdown : CollectionDropdown // TODO: class is now unused. if we decide this isn't coming back it can be nuked. { protected override bool ShowManageCollectionsItem => false; diff --git a/osu.Game/Overlays/Music/Playlist.cs b/osu.Game/Overlays/Music/Playlist.cs index ab51ca7e1d..d7f35e6131 100644 --- a/osu.Game/Overlays/Music/Playlist.cs +++ b/osu.Game/Overlays/Music/Playlist.cs @@ -1,67 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Linq; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.Containers; -using osuTK; namespace osu.Game.Overlays.Music { - public partial class Playlist : OsuRearrangeableListContainer> + public partial class Playlist : VirtualisedListContainer, PlaylistItem> { - public Action>? RequestSelection; - - public readonly Bindable> SelectedSet = new Bindable>(); - - private FilterCriteria currentCriteria = new FilterCriteria(); - public new MarginPadding Padding { get => base.Padding; set => base.Padding = value; } - protected override void OnItemsChanged() + public Playlist() + : base(20, 50) { - base.OnItemsChanged(); - Filter(currentCriteria); } - public void Filter(FilterCriteria criteria) - { - var items = (SearchContainer>>)ListContainer; - - string[]? currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray()); - - foreach (var item in items.OfType()) - { - item.InSelectedCollection = currentCollectionHashes == null || item.Model.Value.Beatmaps.Select(b => b.MD5Hash).Any(currentCollectionHashes.Contains); - } - - items.SearchTerm = criteria.SearchText; - currentCriteria = criteria; - } - - public Live? FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter); - - protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) => - new PlaylistItem(item) - { - SelectedSet = { BindTarget = SelectedSet }, - RequestSelection = set => RequestSelection?.Invoke(set) - }; - - protected override FillFlowContainer>> CreateListFillFlowContainer() => new SearchContainer>> - { - Spacing = new Vector2(0, 3), - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, - }; + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); } } diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 90fdfd0491..5cbde6ba57 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -1,15 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; @@ -20,119 +18,111 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Music { - public partial class PlaylistItem : OsuRearrangeableListItem>, IFilterable + public partial class PlaylistItem : PoolableDrawable, IHasCurrentValue> { - public readonly Bindable> SelectedSet = new Bindable>(); - - public Action> RequestSelection; - - private TextFlowContainer text; - private ITextPart titlePart; - - [Resolved] - private OsuColour colours { get; set; } - - public PlaylistItem(Live item) - : base(item) + public Bindable> Current { - Padding = new MarginPadding { Left = 5 }; + get => current.Current; + set => current.Current = value; } + private readonly BindableWithCurrent> current = new BindableWithCurrent>(); + + private readonly Bindable?> selectedSet = new Bindable?>(); + private Action>? requestSelection; + + private MarqueeContainer text = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + [BackgroundDependencyLoader] - private void load() + private void load(PlaylistOverlay playlistOverlay) { - HandleColour = colours.Gray5; + RelativeSizeAxes = Axes.X; + Height = 20; + + InternalChild = text = new MarqueeContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + InitialMoveDelay = 0, + AllowScrolling = false, + Padding = new MarginPadding { Horizontal = 15 }, + }; + + selectedSet.BindTo(playlistOverlay.SelectedSet); + requestSelection = playlistOverlay.ItemSelected; } protected override void LoadComplete() { base.LoadComplete(); + Current.BindValueChanged(_ => onItemChanged(), true); + selectedSet.BindValueChanged(updateSelectionState, true); + } - Model.PerformRead(m => + private void onItemChanged() => Current.Value.PerformRead(m => + { + var metadata = m.Metadata; + + var title = new RomanisableString(metadata.TitleUnicode, metadata.Title); + var artist = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); + + text.CreateContent = () => { - var metadata = m.Metadata; + var flow = new OsuTextFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }; - var title = new RomanisableString(metadata.TitleUnicode, metadata.Title); - var artist = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); - - titlePart = text.AddText(title, sprite => sprite.Font = OsuFont.GetFont(weight: FontWeight.Regular)); - titlePart.DrawablePartsRecreated += _ => updateSelectionState(SelectedSet.Value, applyImmediately: true); - - text.AddText(@" "); // to separate the title from the artist. - text.AddText(artist, sprite => + flow.AddText(title, sprite => sprite.Font = OsuFont.GetFont(weight: FontWeight.Regular)); + flow.AddText(@" "); // to separate the title from the artist. + flow.AddText(artist, sprite => { sprite.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold); sprite.Colour = colours.Gray9; - sprite.Padding = new MarginPadding { Top = 1 }; }); + return flow; + }; - SelectedSet.BindValueChanged(set => updateSelectionState(set.NewValue)); - updateSelectionState(SelectedSet.Value, applyImmediately: true); - }); - } + selectedSet.TriggerChange(); + FinishTransforms(true); + }); - private bool selected; + private bool? selected; - private void updateSelectionState(Live selectedSet, bool applyImmediately = false) + private void updateSelectionState(ValueChangedEvent?> selected) { - bool wasSelected = selected; - selected = selectedSet?.Equals(Model) == true; + bool? wasSelected = this.selected; + this.selected = selected.NewValue?.Equals(Current.Value) == true; - // Immediate updates should forcibly set correct state regardless of previous state. - // This ensures that the initial state is correctly applied. - if (wasSelected == selected && !applyImmediately) + if (wasSelected == this.selected) return; - foreach (Drawable s in titlePart.Drawables) - s.FadeColour(selected ? colours.Yellow : Color4.White, applyImmediately ? 0 : FADE_DURATION); + text.FadeColour(this.selected == true ? colours.Yellow : Color4.White, 100); } - protected override Drawable CreateContent() => new DelayedLoadWrapper(text = new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }); - protected override bool OnClick(ClickEvent e) { - RequestSelection?.Invoke(Model); + requestSelection?.Invoke(Current.Value); return true; } - private bool inSelectedCollection = true; - - public bool InSelectedCollection + protected override bool OnHover(HoverEvent e) { - get => inSelectedCollection; - set - { - if (inSelectedCollection == value) - return; - - inSelectedCollection = value; - updateFilter(); - } + text.AllowScrolling = true; + return true; } - public IEnumerable FilterTerms => Model.PerformRead(m => m.Metadata.GetSearchableTerms()).Select(s => (LocalisableString)s).ToArray(); - - private bool matchingFilter = true; - - public bool MatchingFilter + protected override void OnHoverLost(HoverLostEvent e) { - get => matchingFilter && inSelectedCollection; - set - { - if (matchingFilter == value) - return; - - matchingFilter = value; - updateFilter(); - } + text.AllowScrolling = false; + base.OnHoverLost(e); } - - private void updateFilter() => this.FadeTo(MatchingFilter ? 1 : 0, 200); - - public bool FilteringActive { get; set; } } } diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index b49c794aa3..99ae88701a 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Linq; using osu.Framework.Allocation; @@ -21,8 +19,11 @@ using Realms; namespace osu.Game.Overlays.Music { + [Cached] public partial class PlaylistOverlay : VisibilityContainer { + public Bindable?> SelectedSet = new Bindable?>(); + private const float transition_duration = 600; public const float PLAYLIST_HEIGHT = 510; @@ -31,15 +32,14 @@ namespace osu.Game.Overlays.Music private readonly Bindable beatmap = new Bindable(); [Resolved] - private BeatmapManager beatmaps { get; set; } + private BeatmapManager beatmaps { get; set; } = null!; [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; - private IDisposable beatmapSubscription; + private IDisposable? beatmapSubscription; - private FilterControl filter; - private Playlist list; + private Playlist list = null!; [BackgroundDependencyLoader] private void load(OsuColour colours, Bindable beatmap) @@ -69,33 +69,11 @@ namespace osu.Game.Overlays.Music list = new Playlist { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 95, Bottom = 10, Right = 10 }, - RequestSelection = itemSelected - }, - filter = new FilterControl - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - FilterChanged = criteria => list.Filter(criteria), - Padding = new MarginPadding(10), + Padding = new MarginPadding { Vertical = 10, Right = 10 }, }, }, }, }; - - filter.Search.OnCommit += (_, _) => - { - list.FirstVisibleSet?.PerformRead(set => - { - BeatmapInfo toSelect = set.Beatmaps.FirstOrDefault(); - - if (toSelect != null) - { - beatmap.Value = beatmaps.GetWorkingBeatmap(toSelect); - beatmap.Value.Track.Restart(); - } - }); - }; } protected override void LoadComplete() @@ -104,11 +82,11 @@ namespace osu.Game.Overlays.Music beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending && !s.Protected), beatmapsChanged); - list.Items.BindTo(beatmapSets); - beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true); + list.RowData.BindTo(beatmapSets); + beatmap.BindValueChanged(working => SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true); } - private void beatmapsChanged(IRealmCollection sender, ChangeSet changes) + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes) { if (changes == null) { @@ -127,22 +105,17 @@ namespace osu.Game.Overlays.Music protected override void PopIn() { - filter.Search.HoldFocus = true; - Schedule(() => filter.Search.TakeFocus()); - this.ResizeTo(new Vector2(1, RelativeSizeAxes.HasFlag(Axes.Y) ? 1f : PLAYLIST_HEIGHT), transition_duration, Easing.OutQuint); this.FadeIn(transition_duration, Easing.OutQuint); } protected override void PopOut() { - filter.Search.HoldFocus = false; - this.ResizeTo(new Vector2(1, 0), transition_duration, Easing.OutQuint); this.FadeOut(transition_duration); } - private void itemSelected(Live beatmapSet) + public void ItemSelected(Live beatmapSet) { beatmapSet.PerformRead(set => { diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 87920fdf55..8bb88fc8e9 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -20,6 +20,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets.Mods; +using osu.Game.Seasonal; namespace osu.Game.Overlays { @@ -72,9 +73,9 @@ namespace osu.Game.Overlays private AudioFilter audioDuckFilter = null!; private readonly Bindable randomSelectAlgorithm = new Bindable(); - private readonly List> previousRandomSets = new List>(); - private int randomHistoryDirection; - private int lastRandomTrackDirection; + + private readonly LinkedList> randomHistory = new LinkedList>(); + private LinkedListNode>? currentRandomHistoryPosition; [BackgroundDependencyLoader] private void load(AudioManager audio, OsuConfigManager configManager) @@ -256,8 +257,8 @@ namespace osu.Game.Overlays playableSet = getNextRandom(-1, allowProtectedTracks); else { - playableSet = getBeatmapSets().TakeWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Value.Protected || allowProtectedTracks) - ?? getBeatmapSets().LastOrDefault(s => !s.Value.Protected || allowProtectedTracks); + playableSet = getBeatmapSets(allowProtectedTracks).TakeWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)).LastOrDefault() + ?? getBeatmapSets(allowProtectedTracks).LastOrDefault(); } if (playableSet != null) @@ -352,10 +353,8 @@ namespace osu.Game.Overlays playableSet = getNextRandom(1, allowProtectedTracks); else { - playableSet = getBeatmapSets().SkipWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)) - .Where(i => !i.Value.Protected || allowProtectedTracks) - .ElementAtOrDefault(1) - ?? getBeatmapSets().FirstOrDefault(i => !i.Value.Protected || allowProtectedTracks); + playableSet = getBeatmapSets(allowProtectedTracks).SkipWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)).ElementAtOrDefault(1) + ?? getBeatmapSets(allowProtectedTracks).FirstOrDefault(); } var playableBeatmap = playableSet?.Value.Beatmaps.FirstOrDefault(); @@ -372,80 +371,75 @@ namespace osu.Game.Overlays private Live? getNextRandom(int direction, bool allowProtectedTracks) { - try + Live result; + + var possibleSets = getBeatmapSets(allowProtectedTracks).ToList(); + + if (possibleSets.Count == 0) + return null; + + // if there is only one possible set left, play it, even if it is the same as the current track. + // looping is preferable over playing nothing. + if (possibleSets.Count == 1) + return possibleSets.Single(); + + // now that we actually know there is a choice, do not allow the current track to be played again. + possibleSets.RemoveAll(s => s.Value.Equals(current?.BeatmapSetInfo)); + + if (currentRandomHistoryPosition != null) { - Live result; - - var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToList(); - - if (possibleSets.Count == 0) - return null; - - // if there is only one possible set left, play it, even if it is the same as the current track. - // looping is preferable over playing nothing. - if (possibleSets.Count == 1) - return possibleSets.Single(); - - // now that we actually know there is a choice, do not allow the current track to be played again. - possibleSets.RemoveAll(s => s.Value.Equals(current?.BeatmapSetInfo)); - - // condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero. - // if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back, - // or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward. - // in both cases, it means that we have a history of previous random selections that we can rewind. - if (randomHistoryDirection * direction < 0) + if (direction < 0 && currentRandomHistoryPosition.Previous != null) { - Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count); - - // if the user has been shuffling backwards and now going forwards (or vice versa), - // the topmost item from history needs to be discarded because it's the *current* track. - if (direction * lastRandomTrackDirection < 0) - { - previousRandomSets.RemoveAt(previousRandomSets.Count - 1); - randomHistoryDirection += direction; - } - - if (previousRandomSets.Count > 0) - { - result = previousRandomSets[^1]; - previousRandomSets.RemoveAt(previousRandomSets.Count - 1); - return result; - } + currentRandomHistoryPosition = currentRandomHistoryPosition.Previous; + return currentRandomHistoryPosition.Value; } - // if the early-return above didn't cover it, it means that we have no history to fall back on - // and need to actually choose something random. - switch (randomSelectAlgorithm.Value) + if (direction > 0 && currentRandomHistoryPosition.Next != null) { - case RandomSelectAlgorithm.Random: - result = possibleSets[RNG.Next(possibleSets.Count)]; - break; - - case RandomSelectAlgorithm.RandomPermutation: - var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToList(); - - if (notYetPlayedSets.Count == 0) - { - notYetPlayedSets = possibleSets; - previousRandomSets.Clear(); - randomHistoryDirection = 0; - } - - result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Count)]; - break; - - default: - throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm"); + currentRandomHistoryPosition = currentRandomHistoryPosition.Next; + return currentRandomHistoryPosition.Value; } + } - previousRandomSets.Add(result); - return result; - } - finally + // if the early-return above didn't cover it, it means that we have no history to fall back on + // and need to actually choose something random. + + switch (randomSelectAlgorithm.Value) { - randomHistoryDirection += direction; - lastRandomTrackDirection = direction; + case RandomSelectAlgorithm.Random: + result = possibleSets[RNG.Next(possibleSets.Count)]; + break; + + case RandomSelectAlgorithm.RandomPermutation: + var notYetPlayedSets = possibleSets.Except(randomHistory).ToList(); + + if (notYetPlayedSets.Count == 0) + { + possibleSets.RemoveAll(s => s.Value.Equals(current?.BeatmapSetInfo)); + notYetPlayedSets = possibleSets; + randomHistory.Clear(); + } + + result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Count)]; + + Debug.Assert(randomHistory.Count == 0 + || (currentRandomHistoryPosition == randomHistory.First && direction < 0) + || (currentRandomHistoryPosition == randomHistory.Last && direction > 0)); + + // notably, this depends solely on `direction` specifically, because when there are less than 2 items in `randomHistory`, + // we have `randomHistory.First == randomHistory.Last` (either `null` if no items, or the single item). + // the assert above should make that safe to depend on. + if (direction > 0) + currentRandomHistoryPosition = randomHistory.AddLast(result); + else if (direction < 0) + currentRandomHistoryPosition = randomHistory.AddFirst(result); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm"); } + + return result; } private void restartTrack() @@ -459,9 +453,12 @@ namespace osu.Game.Overlays private TrackChangeDirection? queuedDirection; - private IEnumerable> getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending) - .AsEnumerable() - .Select(s => new RealmLive(s, realm)); + private IEnumerable> getBeatmapSets(bool allowProtectedTracks) => + realm.Realm.All().Where(s => !s.DeletePending) + .AsEnumerable() + .Select(s => new RealmLive(s, realm)) + .Where(i => (allowProtectedTracks || !i.Value.Protected) + && (SeasonalUIConfig.ENABLED || i.Value.Hash != IntroChristmas.CHRISTMAS_BEATMAP_SET_HASH)); private void changeBeatmap(WorkingBeatmap newWorking) { @@ -488,8 +485,8 @@ namespace osu.Game.Overlays else { // figure out the best direction based on order in playlist. - int last = getBeatmapSets().TakeWhile(b => !b.Value.Equals(current.BeatmapSetInfo)).Count(); - int next = getBeatmapSets().TakeWhile(b => !b.Value.Equals(newWorking.BeatmapSetInfo)).Count(); + int last = getBeatmapSets(allowProtectedTracks: false).TakeWhile(b => !b.Value.Equals(current.BeatmapSetInfo)).Count(); + int next = getBeatmapSets(allowProtectedTracks: false).TakeWhile(b => !b.Value.Equals(newWorking.BeatmapSetInfo)).Count(); direction = last > next ? TrackChangeDirection.Prev : TrackChangeDirection.Next; } diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index cb9d940a05..81ac67bd89 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -136,7 +136,7 @@ namespace osu.Game.Overlays { base.UpdateAfterChildren(); sidebarContainer.Height = DrawHeight; - sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + sidebarContainer.Y = (float)Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); } private void loadListing(int? year = null) diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index 18a487a312..7ef2fffeda 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -162,16 +162,17 @@ namespace osu.Game.Overlays private int runningDepth; private readonly Scheduler postScheduler = new Scheduler(); + private readonly Scheduler criticalPostScheduler = new Scheduler(); public override bool IsPresent => // Delegate presence as we need to consider the toast tray in addition to the main overlay. - State.Value == Visibility.Visible || mainContent.IsPresent || toastTray.IsPresent || postScheduler.HasPendingTasks; + State.Value == Visibility.Visible || mainContent.IsPresent || toastTray.IsPresent || postScheduler.HasPendingTasks || criticalPostScheduler.HasPendingTasks; private bool processingPosts = true; private double? lastSamplePlayback; - public void Post(Notification notification) => postScheduler.Add(() => + public void Post(Notification notification) => (notification.IsCritical ? criticalPostScheduler : postScheduler).Add(() => { ++runningDepth; @@ -180,7 +181,7 @@ namespace osu.Game.Overlays notification.Closed += () => notificationClosed(notification); if (notification is IHasCompletionTarget hasCompletionTarget) - hasCompletionTarget.CompletionTarget = Post; + hasCompletionTarget.CompletionTarget ??= Post; playDebouncedSample(notification.PopInSampleName); @@ -220,6 +221,8 @@ namespace osu.Game.Overlays { base.Update(); + criticalPostScheduler.Update(); + if (processingPosts) postScheduler.Update(); } diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index df07b4f138..e66b999540 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -41,7 +41,7 @@ namespace osu.Game.Overlays [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public Action? ForwardNotificationToPermanentStore { get; set; } + public required Action ForwardNotificationToPermanentStore { get; init; } public int UnreadCount => Notifications.Count(n => !n.WasClosed && !n.Read); @@ -91,7 +91,12 @@ namespace osu.Game.Overlays public void FlushAllToasts() { foreach (var notification in toastFlow.ToArray()) + { + if (notification.IsCritical) + continue; + forwardNotification(notification); + } } public void Post(Notification notification) @@ -142,8 +147,15 @@ namespace osu.Game.Overlays notification.MoveToOffset(new Vector2(400, 0), NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint); notification.FadeOut(NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint).OnComplete(_ => { + if (notification.Transient) + { + notification.IsInToastTray = false; + notification.Close(false); + return; + } + RemoveInternal(notification, false); - ForwardNotificationToPermanentStore?.Invoke(notification); + ForwardNotificationToPermanentStore(notification); notification.FadeIn(300, Easing.OutQuint); }); @@ -167,7 +179,7 @@ namespace osu.Game.Overlays } height = toastFlow.DrawHeight + 120; - alpha = MathHelper.Clamp(toastFlow.DrawHeight / 41, 0, 1) * maxNotificationAlpha; + alpha = Math.Clamp(toastFlow.DrawHeight / 41, 0, 1) * maxNotificationAlpha; } toastContentBackground.Height = (float)Interpolation.DampContinuously(toastContentBackground.Height, height, 10, Clock.ElapsedFrameTime); diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index d48524d8b0..8a2a7cee81 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -34,9 +34,20 @@ namespace osu.Game.Overlays.Notifications public abstract LocalisableString Text { get; set; } /// - /// Whether this notification should forcefully display itself. + /// Important notifications display for longer, and announce themselves at an OS level (ie flashing the taskbar). + /// This defaults to true. /// - public virtual bool IsImportant => true; + public bool IsImportant { get; init; } = true; + + /// + /// Critical notifications show even during gameplay or other scenarios where notifications would usually be suppressed. + /// + public bool IsCritical { get; init; } + + /// + /// Transient notifications only show as a toast, and do not linger in notification history. + /// + public bool Transient { get; init; } /// /// Run on user activating the notification. Return true to close. diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 2362cb11f6..0b42188252 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -191,8 +191,6 @@ namespace osu.Game.Overlays.Notifications public override bool DisplayOnTop => false; - public override bool IsImportant => false; - private readonly ProgressBar progressBar; private Color4 colourQueued; private Color4 colourActive; @@ -206,6 +204,8 @@ namespace osu.Game.Overlays.Notifications public ProgressNotification() { + IsImportant = false; + Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) { AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Overlays/Notifications/SimpleNotification.cs b/osu.Game/Overlays/Notifications/SimpleNotification.cs index 109b31ff71..517d7ead43 100644 --- a/osu.Game/Overlays/Notifications/SimpleNotification.cs +++ b/osu.Game/Overlays/Notifications/SimpleNotification.cs @@ -24,8 +24,7 @@ namespace osu.Game.Overlays.Notifications set { text = value; - if (textDrawable != null) - textDrawable.Text = text; + TextFlow.Text = text; } } @@ -37,8 +36,7 @@ namespace osu.Game.Overlays.Notifications set { icon = value; - if (iconDrawable != null) - iconDrawable.Icon = icon; + IconDrawable.Icon = icon; } } @@ -48,39 +46,6 @@ namespace osu.Game.Overlays.Notifications set => IconContent.Colour = value; } - private TextFlowContainer? textDrawable; - - private SpriteIcon? iconDrawable; - - [BackgroundDependencyLoader] - private void load(OsuColour colours, OverlayColourProvider colourProvider) - { - Light.Colour = colours.Green; - - IconContent.AddRange(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, - iconDrawable = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = icon, - Size = new Vector2(16), - } - }); - - Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Text = text - }); - } - public override bool Read { get => base.Read; @@ -92,5 +57,42 @@ namespace osu.Game.Overlays.Notifications Light.FadeTo(value ? 0 : 1, 100); } } + + protected TextFlowContainer TextFlow { get; } + protected SpriteIcon IconDrawable { get; } + + private readonly Box iconBackground; + + public SimpleNotification() + { + IconContent.AddRange(new Drawable[] + { + iconBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + IconDrawable = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = icon, + Size = new Vector2(16), + } + }); + + Content.Add(TextFlow = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Text = text + }); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + Light.Colour = colours.Green; + iconBackground.Colour = colourProvider.Background5; + } } } diff --git a/osu.Game/Overlays/Notifications/UserAvatarNotification.cs b/osu.Game/Overlays/Notifications/UserAvatarNotification.cs index 5a9241a2a1..fcc1d59dde 100644 --- a/osu.Game/Overlays/Notifications/UserAvatarNotification.cs +++ b/osu.Game/Overlays/Notifications/UserAvatarNotification.cs @@ -1,74 +1,46 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; using osu.Game.Users.Drawables; namespace osu.Game.Overlays.Notifications { - public partial class UserAvatarNotification : Notification + public abstract partial class UserAvatarNotification : SimpleNotification { - private LocalisableString text; + private readonly APIUser? user; - public override LocalisableString Text - { - get => text; - set - { - text = value; - if (textDrawable != null) - textDrawable.Text = text; - } - } + protected DrawableAvatar Avatar { get; private set; } = null!; - private TextFlowContainer? textDrawable; - - private readonly APIUser user; - - public UserAvatarNotification(APIUser user, LocalisableString text) + protected UserAvatarNotification(APIUser? user, LocalisableString text = default) { this.user = user; + + Icon = default; Text = text; } - protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; - [BackgroundDependencyLoader] - private void load(OsuColour colours, OverlayColourProvider colourProvider) + private void load() { - Light.Colour = colours.Orange2; - - Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Text = text - }); - IconContent.Masking = true; IconContent.CornerRadius = CORNER_RADIUS; + IconContent.ChangeChildDepth(IconDrawable, float.MinValue); - IconContent.AddRange(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, - }); - - LoadComponentAsync(new DrawableAvatar(user) + LoadComponentAsync(Avatar = new DrawableAvatar(user) { FillMode = FillMode.Fill, }, IconContent.Add); } + + protected override void Update() + { + base.Update(); + IconContent.Width = Math.Min(78, IconContent.DrawHeight); + } } } diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index f4da9a92dc..84c279476f 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -5,7 +5,6 @@ using System; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Configuration; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -50,7 +49,7 @@ namespace osu.Game.Overlays private MusicIconButton shuffleButton = null!; private IconButton playlistButton = null!; - private ScrollingTextContainer title = null!, artist = null!; + private MarqueeContainer title = null!, artist = null!; private PlaylistOverlay? playlist; @@ -72,6 +71,9 @@ namespace osu.Game.Overlays private Bindable allowTrackControl = null!; private readonly BindableBool shuffle = new BindableBool(true); + private static readonly FontUsage title_font = OsuFont.GetFont(size: 25, italics: true); + private static readonly FontUsage artist_font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold, italics: true); + public NowPlayingOverlay() { Width = player_width; @@ -105,23 +107,37 @@ namespace osu.Game.Overlays Children = new[] { background = Empty(), - title = new ScrollingTextContainer + title = new MarqueeContainer { Origin = Anchor.BottomCentre, Anchor = Anchor.TopCentre, Position = new Vector2(0, 40), - Font = OsuFont.GetFont(size: 25, italics: true), Colour = Color4.White, - Text = @"Nothing to play", + CreateContent = () => new OsuSpriteText + { + Font = title_font, + Text = @"Nothing to play", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + NonOverflowingContentAnchor = Anchor.Centre, + Padding = new MarginPadding { Horizontal = 15 }, }, - artist = new ScrollingTextContainer + artist = new MarqueeContainer { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, Position = new Vector2(0, 45), - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold, italics: true), Colour = Color4.White, - Text = @"Nothing to play", + CreateContent = () => new OsuSpriteText + { + Font = artist_font, + Text = @"Nothing to play", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + NonOverflowingContentAnchor = Anchor.Centre, + Padding = new MarginPadding { Horizontal = 15 }, }, new Container { @@ -288,18 +304,21 @@ namespace osu.Game.Overlays var track = musicController.CurrentTrack; - if (!track.IsDummyDevice) + if (!progressBar.Seeking) { - progressBar.EndTime = track.Length; - progressBar.CurrentTime = track.CurrentTime; + if (!track.IsDummyDevice) + { + progressBar.EndTime = track.Length; + progressBar.CurrentTime = track.CurrentTime; - playButton.Icon = track.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; - } - else - { - progressBar.CurrentTime = 0; - progressBar.EndTime = 1; - playButton.Icon = FontAwesome.Regular.PlayCircle; + playButton.Icon = track.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; + } + else + { + progressBar.CurrentTime = 0; + progressBar.EndTime = 1; + playButton.Icon = FontAwesome.Regular.PlayCircle; + } } } @@ -318,8 +337,20 @@ namespace osu.Game.Overlays { BeatmapMetadata metadata = beatmap.Metadata; - title.Text = new RomanisableString(metadata.TitleUnicode, metadata.Title); - artist.Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); + title.CreateContent = () => new OsuSpriteText + { + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), + Font = title_font, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + artist.CreateContent = () => new OsuSpriteText + { + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), + Font = artist_font, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; backgroundLoadCancellation?.Cancel(); @@ -484,111 +515,5 @@ namespace osu.Game.Overlays base.OnHoverLost(e); } } - - private partial class ScrollingTextContainer : CompositeDrawable - { - private const float initial_move_delay = 1000; - private const float pixels_per_second = 50; - - private OsuSpriteText mainSpriteText = null!; - private OsuSpriteText fillerSpriteText = null!; - - private Bindable showUnicode = null!; - - [Resolved] - private FrameworkConfigManager frameworkConfig { get; set; } = null!; - - private LocalisableString text; - - public LocalisableString Text - { - get => text; - set - { - text = value; - - if (IsLoaded) - updateText(); - } - } - - private FontUsage font = OsuFont.Default; - - public FontUsage Font - { - get => font; - set - { - font = value; - - if (IsLoaded) - updateFontAndText(); - } - } - - public ScrollingTextContainer() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - mainSpriteText = new OsuSpriteText { Padding = new MarginPadding { Horizontal = margin } }, - fillerSpriteText = new OsuSpriteText { Padding = new MarginPadding { Horizontal = margin }, Alpha = 0 }, - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - showUnicode = frameworkConfig.GetBindable(FrameworkSetting.ShowUnicode); - showUnicode.BindValueChanged(_ => updateText()); - - updateFontAndText(); - } - - private void updateFontAndText() - { - mainSpriteText.Font = font; - fillerSpriteText.Font = font; - - updateText(); - } - - private void updateText() - { - mainSpriteText.Text = text; - fillerSpriteText.Alpha = 0; - - ClearTransforms(); - X = 0; - - float textOverflowWidth = mainSpriteText.Width - player_width; - - // apply half margin of tolerance on both sides before the text scrolls - if (textOverflowWidth > margin) - { - fillerSpriteText.Alpha = 1; - fillerSpriteText.Text = text; - - float initialX = (textOverflowWidth + mainSpriteText.Width) / 2; - float targetX = (textOverflowWidth - mainSpriteText.Width) / 2; - - this.MoveToX(initialX) - .Delay(initial_move_delay) - .MoveToX(targetX, mainSpriteText.Width * 1000 / pixels_per_second) - .Loop(); - } - } - } } } diff --git a/osu.Game/Overlays/OSD/CopyUrlToast.cs b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs similarity index 58% rename from osu.Game/Overlays/OSD/CopyUrlToast.cs rename to osu.Game/Overlays/OSD/CopiedToClipboardToast.cs index 2c5a9179f2..4059a274ad 100644 --- a/osu.Game/Overlays/OSD/CopyUrlToast.cs +++ b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs @@ -5,10 +5,10 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.OSD { - public partial class CopyUrlToast : Toast + public partial class CopiedToClipboardToast : Toast { - public CopyUrlToast() - : base(CommonStrings.General, ToastStrings.UrlCopied, "") + public CopiedToClipboardToast() + : base(CommonStrings.General, ToastStrings.CopiedToClipboard, "") { } } diff --git a/osu.Game/Overlays/OSD/SpeedChangeToast.cs b/osu.Game/Overlays/OSD/SpeedChangeToast.cs index 49d3985b04..652c043357 100644 --- a/osu.Game/Overlays/OSD/SpeedChangeToast.cs +++ b/osu.Game/Overlays/OSD/SpeedChangeToast.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Configuration; +using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -9,8 +9,8 @@ namespace osu.Game.Overlays.OSD { public partial class SpeedChangeToast : Toast { - public SpeedChangeToast(OsuConfigManager config, double newSpeed) - : base(ModSelectOverlayStrings.ModCustomisation, ToastStrings.SpeedChangedTo(newSpeed), config.LookupKeyBindings(GlobalAction.IncreaseModSpeed) + " / " + config.LookupKeyBindings(GlobalAction.DecreaseModSpeed)) + public SpeedChangeToast(RealmKeyBindingStore keyBindingStore, double newSpeed) + : base(ModSelectOverlayStrings.ModCustomisation, ToastStrings.SpeedChangedTo(newSpeed), keyBindingStore.GetBindingsStringFor(GlobalAction.IncreaseModSpeed) + " / " + keyBindingStore.GetBindingsStringFor(GlobalAction.DecreaseModSpeed)) { } } diff --git a/osu.Game/Overlays/OnScreenDisplay.cs b/osu.Game/Overlays/OnScreenDisplay.cs index 4f2dba7b2c..c2ffb8ba6c 100644 --- a/osu.Game/Overlays/OnScreenDisplay.cs +++ b/osu.Game/Overlays/OnScreenDisplay.cs @@ -5,8 +5,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; @@ -56,8 +59,11 @@ namespace osu.Game.Overlays /// The to be tracked. /// If is null. /// If is already being tracked from the same . - public void BeginTracking(object source, ITrackableConfigManager configManager) + /// An object representing the registration, that may be disposed to stop tracking the . + public IDisposable BeginTracking(object source, ITrackableConfigManager configManager) { + Debug.Assert(ThreadSafety.IsUpdateThread); + ArgumentNullException.ThrowIfNull(configManager); if (trackedConfigManagers.ContainsKey((source, configManager))) @@ -65,32 +71,18 @@ namespace osu.Game.Overlays var trackedSettings = configManager.CreateTrackedSettings(); if (trackedSettings == null) - return; + return new InvokeOnDisposal(() => { }); configManager.LoadInto(trackedSettings); trackedSettings.SettingChanged += displayTrackedSettingChange; - trackedConfigManagers.Add((source, configManager), trackedSettings); - } - /// - /// Unregisters a from having its settings tracked by this . - /// - /// The object that registered the to be tracked. - /// The that is being tracked. - /// If is null. - /// If is not being tracked from the same . - public void StopTracking(object source, ITrackableConfigManager configManager) - { - ArgumentNullException.ThrowIfNull(configManager); - - if (!trackedConfigManagers.TryGetValue((source, configManager), out var existing)) - return; - - existing.Unload(); - existing.SettingChanged -= displayTrackedSettingChange; - - trackedConfigManagers.Remove((source, configManager)); + return new InvokeOnDisposal(() => + { + trackedSettings.Unload(); + trackedSettings.SettingChanged -= displayTrackedSettingChange; + trackedConfigManagers.Remove((source, configManager)); + }); } /// diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs index 051873b394..cc5a1b9d2d 100644 --- a/osu.Game/Overlays/OnlineOverlay.cs +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -88,7 +88,7 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); // don't block header by applying padding equal to the visible header height - loadingContainer.Padding = new MarginPadding { Top = Math.Max(0, Header.Height - ScrollFlow.Current) }; + loadingContainer.Padding = new MarginPadding { Top = (float)Math.Max(0, Header.Height - ScrollFlow.Current) }; } } } diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 4328977a8d..a197748687 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -14,6 +15,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -35,7 +37,8 @@ namespace osu.Game.Overlays public ScrollBackButton Button { get; private set; } - private readonly Bindable lastScrollTarget = new Bindable(); + private readonly Bindable lastScrollTarget = new Bindable(); + private readonly Bindable progress = new Bindable(); [BackgroundDependencyLoader] private void load() @@ -46,7 +49,8 @@ namespace osu.Game.Overlays Origin = Anchor.BottomRight, Margin = new MarginPadding(20), Action = scrollBack, - LastScrollTarget = { BindTarget = lastScrollTarget } + LastScrollTarget = { BindTarget = lastScrollTarget }, + Progress = { BindTarget = progress }, }); } @@ -54,6 +58,10 @@ namespace osu.Game.Overlays { base.UpdateAfterChildren(); + // Map current position to standardized progress + float height = AvailableContent - DrawHeight; + progress.Value = height == 0 ? 1 : Math.Round(Math.Clamp(Current / height, 0, 1), 3); + if (ScrollContent.DrawHeight + button_scroll_position < DrawHeight) { Button.State = Visibility.Hidden; @@ -63,7 +71,7 @@ namespace osu.Game.Overlays Button.State = Target > button_scroll_position || lastScrollTarget.Value != null ? Visibility.Visible : Visibility.Hidden; } - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default) { base.OnUserScroll(value, animated, distanceDecay); @@ -110,19 +118,24 @@ namespace osu.Game.Overlays private readonly Container content; private readonly Box background; + private readonly CircularProgress currentCircularProgress; private readonly SpriteIcon spriteIcon; - public Bindable LastScrollTarget = new Bindable(); + public Bindable LastScrollTarget = new Bindable(); + public Bindable Progress = new Bindable(); protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); private Sample scrollToTopSample; private Sample scrollToPreviousSample; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => content.ReceivePositionalInputAt(screenSpacePos); + public ScrollBackButton() { Size = new Vector2(50); Alpha = 0; + Add(content = new CircularContainer { RelativeSizeAxes = Axes.Both, @@ -142,6 +155,11 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both }, + currentCircularProgress = new CircularProgress + { + RelativeSizeAxes = Axes.Both, + InnerRadius = 0.1f, + }, spriteIcon = new SpriteIcon { Anchor = Anchor.Centre, @@ -161,6 +179,7 @@ namespace osu.Game.Overlays IdleColour = colourProvider.Background6; HoverColour = colourProvider.Background5; flashColour = colourProvider.Light1; + currentCircularProgress.Colour = colourProvider.Highlight1; scrollToTopSample = audio.Samples.Get(@"UI/scroll-to-top"); scrollToPreviousSample = audio.Samples.Get(@"UI/scroll-to-previous"); @@ -170,6 +189,8 @@ namespace osu.Game.Overlays { base.LoadComplete(); + Progress.BindValueChanged(p => currentCircularProgress.Progress = p.NewValue, true); + LastScrollTarget.BindValueChanged(target => { spriteIcon.ScaleTo(target.NewValue != null ? new Vector2(1f, -1f) : Vector2.One, fade_duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/OverlayStreamItem.cs b/osu.Game/Overlays/OverlayStreamItem.cs index f0ae0b41fc..ec04a130cf 100644 --- a/osu.Game/Overlays/OverlayStreamItem.cs +++ b/osu.Game/Overlays/OverlayStreamItem.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Framework.Graphics.UserInterface; @@ -22,7 +20,9 @@ namespace osu.Game.Overlays { public abstract partial class OverlayStreamItem : TabItem { - public readonly Bindable SelectedItem = new Bindable(); + public const float PADDING = 5; + + public readonly Bindable SelectedItem = new Bindable(); private bool userHoveringArea; @@ -38,10 +38,12 @@ namespace osu.Game.Overlays } } - private FillFlowContainer text; - private ExpandingBar expandingBar; - - public const float PADDING = 5; + private FillFlowContainer text = null!; + private ExpandingBar expandingBar = null!; + private Sample selectSample = null!; + private OsuSpriteText? mainTextPiece; + private OsuSpriteText? additionalTextPiece; + private OsuSpriteText? infoTextPiece; protected OverlayStreamItem(T value) : base(value) @@ -51,8 +53,6 @@ namespace osu.Game.Overlays Margin = new MarginPadding(PADDING); } - private Sample selectSample; - [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, OsuColour colours, AudioManager audio) { @@ -65,17 +65,17 @@ namespace osu.Game.Overlays Margin = new MarginPadding { Top = 6 }, Children = new[] { - new OsuSpriteText + mainTextPiece = new OsuSpriteText { Text = MainText, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), }, - new OsuSpriteText + additionalTextPiece = new OsuSpriteText { Text = AdditionalText, Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), }, - new OsuSpriteText + infoTextPiece = new OsuSpriteText { Text = InfoText, Font = OsuFont.GetFont(size: 10), @@ -99,11 +99,47 @@ namespace osu.Game.Overlays SelectedItem.BindValueChanged(_ => updateState(), true); } - protected abstract LocalisableString MainText { get; } + private LocalisableString mainText; - protected abstract LocalisableString AdditionalText { get; } + protected LocalisableString MainText + { + get => mainText; + set + { + mainText = value; - protected virtual LocalisableString InfoText => string.Empty; + if (mainTextPiece != null) + mainTextPiece.Text = value; + } + } + + private LocalisableString additionalText; + + protected LocalisableString AdditionalText + { + get => additionalText; + set + { + additionalText = value; + + if (additionalTextPiece != null) + additionalTextPiece.Text = value; + } + } + + private LocalisableString infoText; + + protected LocalisableString InfoText + { + get => infoText; + set + { + infoText = value; + + if (infoTextPiece != null) + infoTextPiece.Text = value; + } + } protected abstract Color4 GetBarColour(OsuColour colours); diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index d5b4d844b2..db93ec7e05 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -93,7 +93,7 @@ namespace osu.Game.Overlays.Profile.Header addSpacer(topLinkContainer); - if (user.IsOnline) + if (user.WasRecentlyOnline) { topLinkContainer.AddText(UsersStrings.ShowLastvisitOnline); addSpacer(topLinkContainer); @@ -124,12 +124,12 @@ namespace osu.Game.Overlays.Profile.Header } topLinkContainer.AddText("Contributed "); - topLinkContainer.AddLink("forum post".ToQuantity(user.PostCount, "#,##0"), $"{api.WebsiteRootUrl}/users/{user.Id}/posts", creationParameters: embolden); + topLinkContainer.AddLink("forum post".ToQuantity(user.PostCount, "#,##0"), $"{api.Endpoints.WebsiteUrl}/users/{user.Id}/posts", creationParameters: embolden); addSpacer(topLinkContainer); topLinkContainer.AddText("Posted "); - topLinkContainer.AddLink("comment".ToQuantity(user.CommentsCount, "#,##0"), $"{api.WebsiteRootUrl}/comments?user_id={user.Id}", creationParameters: embolden); + topLinkContainer.AddLink("comment".ToQuantity(user.CommentsCount, "#,##0"), $"{api.Endpoints.WebsiteUrl}/comments?user_id={user.Id}", creationParameters: embolden); string websiteWithoutProtocol = user.Website; diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index d964364510..3f669ebfe3 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -55,6 +55,10 @@ namespace osu.Game.Overlays.Profile.Header { User = { BindTarget = User } }, + new UserActionsButton + { + User = { BindTarget = User } + } } }, new Container diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs index 3e86b2268f..3e48366ae2 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -8,11 +9,14 @@ 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.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { @@ -23,6 +27,11 @@ namespace osu.Game.Overlays.Profile.Header.Components public DailyChallengeTooltipData? TooltipContent { get; private set; } private OsuSpriteText dailyPlayCount = null!; + private Container content = null!; + private CircularContainer completionMark = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; [Resolved] private OsuColour colours { get; set; } = null!; @@ -34,59 +43,95 @@ namespace osu.Game.Overlays.Profile.Header.Components private void load() { AutoSizeAxes = Axes.Both; - CornerRadius = 5; - Masking = true; + + OsuTextFlowContainer label; InternalChildren = new Drawable[] { - new Box + content = new Container { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4, - }, - new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding(5f), AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, + CornerRadius = 6, + BorderThickness = 2, + BorderColour = colourProvider.Background4, + Masking = true, Children = new Drawable[] { - new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + new Box { - AutoSizeAxes = Axes.Both, - // can't use this because osu-web does weird stuff with \\n. - // Text = UsersStrings.ShowDailyChallengeTitle., - Text = "Daily\nChallenge", - Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f }, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, }, - new Container + new FillFlowContainer { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - CornerRadius = 5f, - Masking = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(3f), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, Children = new Drawable[] { - new Box + label = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background6, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f }, }, - dailyPlayCount = new OsuSpriteText + new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - UseFullGlyphHeight = false, - Colour = colourProvider.Content2, - Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + CornerRadius = 3, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + dailyPlayCount = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + UseFullGlyphHeight = false, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + }, + } }, } }, } }, + completionMark = new CircularContainer + { + Alpha = 0, + Size = new Vector2(16), + Anchor = Anchor.TopRight, + Origin = Anchor.Centre, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Lime1, + }, + new SpriteIcon + { + Size = new Vector2(8), + Colour = colourProvider.Background6, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Check, + } + } + }, }; + + // can't use this because osu-web does weird stuff with \\n. + // Text = UsersStrings.ShowDailyChallengeTitle., + label.AddParagraph("Daily\nChallenge"); } protected override void LoadComplete() @@ -112,7 +157,30 @@ namespace osu.Game.Overlays.Profile.Header.Components } dailyPlayCount.Text = DailyChallengeStatsDisplayStrings.UnitDay(stats.PlayCount.ToLocalisableString("N0")); - dailyPlayCount.Colour = colours.ForRankingTier(DailyChallengeStatsTooltip.TierForPlayCount(stats.PlayCount)); + dailyPlayCount.Colour = OsuColour.ForRankingTier(DailyChallengeStatsTooltip.TierForPlayCount(stats.PlayCount)); + + bool playedToday = stats.LastUpdate?.Date == DateTimeOffset.UtcNow.Date; + bool userIsOnOwnProfile = stats.UserID == api.LocalUser.Value.Id; + + if (playedToday && userIsOnOwnProfile) + { + if (completionMark.Alpha > 0.8f) + { + completionMark.ScaleTo(1.2f).ScaleTo(1, 800, Easing.OutElastic); + } + else + { + completionMark.FadeIn(500, Easing.OutExpo); + completionMark.ScaleTo(1.6f).ScaleTo(1, 500, Easing.OutExpo); + } + + content.BorderColour = colours.Lime1; + } + else + { + completionMark.FadeOut(50); + content.BorderColour = colourProvider.Background4; + } TooltipContent = new DailyChallengeTooltipData(colourProvider, stats); diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs index 826b40d70c..fa4937bd1f 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs @@ -36,9 +36,6 @@ namespace osu.Game.Overlays.Profile.Header.Components private Box topBackground = null!; private Box background = null!; - [Resolved] - private OsuColour colours { get; set; } = null!; - [BackgroundDependencyLoader] private void load() { @@ -117,19 +114,19 @@ namespace osu.Game.Overlays.Profile.Header.Components topBackground.Colour = colourProvider.Background5; totalParticipation.Value = DailyChallengeStatsDisplayStrings.UnitDay(statistics.PlayCount.ToLocalisableString(@"N0")); - totalParticipation.ValueColour = colours.ForRankingTier(TierForPlayCount(statistics.PlayCount)); + totalParticipation.ValueColour = OsuColour.ForRankingTier(TierForPlayCount(statistics.PlayCount)); currentDaily.Value = DailyChallengeStatsDisplayStrings.UnitDay(content.Statistics.DailyStreakCurrent.ToLocalisableString(@"N0")); - currentDaily.ValueColour = colours.ForRankingTier(TierForDaily(statistics.DailyStreakCurrent)); + currentDaily.ValueColour = OsuColour.ForRankingTier(TierForDaily(statistics.DailyStreakCurrent)); currentWeekly.Value = DailyChallengeStatsDisplayStrings.UnitWeek(statistics.WeeklyStreakCurrent.ToLocalisableString(@"N0")); - currentWeekly.ValueColour = colours.ForRankingTier(TierForWeekly(statistics.WeeklyStreakCurrent)); + currentWeekly.ValueColour = OsuColour.ForRankingTier(TierForWeekly(statistics.WeeklyStreakCurrent)); bestDaily.Value = DailyChallengeStatsDisplayStrings.UnitDay(statistics.DailyStreakBest.ToLocalisableString(@"N0")); - bestDaily.ValueColour = colours.ForRankingTier(TierForDaily(statistics.DailyStreakBest)); + bestDaily.ValueColour = OsuColour.ForRankingTier(TierForDaily(statistics.DailyStreakBest)); bestWeekly.Value = DailyChallengeStatsDisplayStrings.UnitWeek(statistics.WeeklyStreakBest.ToLocalisableString(@"N0")); - bestWeekly.ValueColour = colours.ForRankingTier(TierForWeekly(statistics.WeeklyStreakBest)); + bestWeekly.ValueColour = OsuColour.ForRankingTier(TierForWeekly(statistics.WeeklyStreakBest)); topTen.Value = statistics.Top10PercentPlacements.ToLocalisableString(@"N0"); topTen.ValueColour = colourProvider.Content2; diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs index 5f100bc882..7e4c747ce8 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs @@ -31,6 +31,8 @@ namespace osu.Game.Overlays.Profile.Header.Components { Child = new Sprite { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, FillMode = FillMode.Fit, RelativeSizeAxes = Axes.Both, Texture = textures.Get(badge.ImageUrl), diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs index c099009ca4..b036b0a305 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Profile.Header.Components Texture = textures.Get(banner.Image), }; - Action = () => game?.OpenUrlExternally($@"{api.WebsiteRootUrl}/community/tournaments/{banner.TournamentId}"); + Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/tournaments/{banner.TournamentId}"); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Profile/Header/Components/ExtendedDetails.cs b/osu.Game/Overlays/Profile/Header/Components/ExtendedDetails.cs index 50fc52600c..777283485d 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExtendedDetails.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExtendedDetails.cs @@ -24,6 +24,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private SpriteText playCount = null!; private SpriteText totalScore = null!; private SpriteText totalHits = null!; + private SpriteText hitsPerPlay = null!; private SpriteText maximumCombo = null!; private SpriteText replaysWatched = null!; @@ -56,6 +57,7 @@ namespace osu.Game.Overlays.Profile.Header.Components new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsPlayCount }, new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsTotalScore }, new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsTotalHits }, + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsHitsPerPlay }, new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsMaximumCombo }, new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsReplaysWatchedByOthers }, } @@ -73,6 +75,7 @@ namespace osu.Game.Overlays.Profile.Header.Components playCount = new OsuSpriteText { Font = font }, totalScore = new OsuSpriteText { Font = font }, totalHits = new OsuSpriteText { Font = font }, + hitsPerPlay = new OsuSpriteText { Font = font }, maximumCombo = new OsuSpriteText { Font = font }, replaysWatched = new OsuSpriteText { Font = font }, } @@ -88,6 +91,11 @@ namespace osu.Game.Overlays.Profile.Header.Components User.BindValueChanged(user => updateStatistics(user.NewValue?.User.Statistics), true); } + private int getHitsPerPlay(UserStatistics statistics) + { + return statistics.PlayCount == 0 ? 0 : statistics.TotalHits / statistics.PlayCount; + } + private void updateStatistics(UserStatistics? statistics) { if (statistics == null) @@ -103,6 +111,7 @@ namespace osu.Game.Overlays.Profile.Header.Components playCount.Text = statistics.PlayCount.ToLocalisableString(@"N0"); totalScore.Text = statistics.TotalScore.ToLocalisableString(@"N0"); totalHits.Text = statistics.TotalHits.ToLocalisableString(@"N0"); + hitsPerPlay.Text = getHitsPerPlay(statistics).ToLocalisableString(@"N0"); maximumCombo.Text = statistics.MaxCombo.ToLocalisableString(@"N0"); replaysWatched.Text = statistics.ReplaysWatched.ToLocalisableString(@"N0"); } diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs index af78d62789..4ebedbf946 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; @@ -15,7 +17,6 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Notifications; using osu.Game.Resources.Localisation.Web; -using SharpCompress; namespace osu.Game.Overlays.Profile.Header.Components { @@ -61,8 +62,11 @@ namespace osu.Game.Overlays.Profile.Header.Components [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + [Resolved] + private IAPIProvider api { get; set; } = null!; + [BackgroundDependencyLoader] - private void load(IAPIProvider api, INotificationOverlay? notifications) + private void load(INotificationOverlay? notifications) { localUser.BindTo(api.LocalUser); @@ -72,15 +76,6 @@ namespace osu.Game.Overlays.Profile.Header.Components updateColor(); }); - User.BindValueChanged(u => - { - followerCount = u.NewValue?.User.FollowerCount ?? 0; - updateStatus(); - }, true); - - apiFriends.BindTo(api.Friends); - apiFriends.BindCollectionChanged((_, _) => Schedule(updateStatus)); - Action += () => { if (User.Value == null) @@ -106,7 +101,7 @@ namespace osu.Game.Overlays.Profile.Header.Components status.Value = FriendStatus.None; } - api.UpdateLocalFriends(); + api.LocalUserState.UpdateFriends(); HideLoadingLayer(); }; @@ -125,6 +120,20 @@ namespace osu.Game.Overlays.Profile.Header.Components }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + apiFriends.BindTo(api.LocalUserState.Friends); + apiFriends.BindCollectionChanged((_, _) => Schedule(updateStatus)); + + User.BindValueChanged(u => + { + followerCount = u.NewValue?.User.FollowerCount ?? 0; + updateStatus(); + }, true); + } + protected override bool OnHover(HoverEvent e) { if (status.Value > FriendStatus.None) @@ -200,16 +209,19 @@ namespace osu.Game.Overlays.Profile.Header.Components case FriendStatus.NotMutual: IdleColour = colour.Green.Opacity(0.7f); - HoverColour = IdleColour.Lighten(0.1f); + HoverColour = IdleColour.Value.Lighten(0.1f); break; case FriendStatus.Mutual: IdleColour = colour.Pink.Opacity(0.7f); - HoverColour = IdleColour.Lighten(0.1f); + HoverColour = IdleColour.Value.Lighten(0.1f); break; + + default: + throw new ArgumentOutOfRangeException(); } - EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint)); + EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour.Value : IdleColour.Value, FADE_DURATION, Easing.OutQuint)); } private enum FriendStatus diff --git a/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs new file mode 100644 index 0000000000..f48d467d87 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs @@ -0,0 +1,137 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class GlobalRankDisplay : CompositeDrawable + { + public Bindable UserStatistics = new Bindable(); + public Bindable HighestRank = new Bindable(); + + private ProfileValueDisplay info = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public GlobalRankDisplay() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = info = new ProfileValueDisplay(big: true) + { + Title = UsersStrings.ShowRankGlobalSimple + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + UserStatistics.BindValueChanged(_ => updateState()); + HighestRank.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + info.Content.Text = UserStatistics.Value?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + info.Content.TooltipText = getGlobalRankTooltipText(); + + var tier = getRankingTier(); + info.Content.Colour = tier == null ? colourProvider.Content2 : OsuColour.ForRankingTier(tier.Value); + info.Content.Font = info.Content.Font.With(weight: tier == null || tier == RankingTier.Iron ? FontWeight.Regular : FontWeight.Bold); + } + + /// + private RankingTier? getRankingTier() + { + var stats = UserStatistics.Value; + + int? rank = stats?.GlobalRank; + float? percent = stats?.GlobalRankPercent; + + if (rank == null || percent == null) + return null; + + if (rank <= 100) + return RankingTier.Lustrous; + + if (percent < 0.0005) + return RankingTier.Radiant; + + if (percent < 0.0015) + return RankingTier.Rhodium; + + if (percent < 0.005) + return RankingTier.Platinum; + + if (percent < 0.015) + return RankingTier.Gold; + + if (percent < 0.05) + return RankingTier.Silver; + + if (percent < 0.15) + return RankingTier.Bronze; + + if (percent < 0.5) + return RankingTier.Iron; + + return null; + } + + private LocalisableString getGlobalRankTooltipText() + { + var rankHighest = HighestRank.Value; + var variants = UserStatistics.Value?.Variants; + + LocalisableString? result = null; + + if (variants?.Count > 0) + { + foreach (var variant in variants) + { + if (variant.GlobalRank != null) + { + var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.GlobalRank.ToLocalisableString("\\##,##0")}"); + + if (result == null) + result = variantText; + else + result = LocalisableString.Interpolate($"{result}\n{variantText}"); + } + } + } + + if (rankHighest != null) + { + var rankHighestText = UsersStrings.ShowRankHighest( + rankHighest.Rank.ToLocalisableString("\\##,##0"), + rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")); + + if (result == null) + result = rankHighestText; + else + result = LocalisableString.Interpolate($"{result}\n{rankHighestText}"); + } + + return result ?? default; + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs index 9b4df7672d..543e353f18 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -27,9 +27,6 @@ namespace osu.Game.Overlays.Profile.Header.Components private OsuSpriteText levelText = null!; private Sprite sprite = null!; - [Resolved] - private OsuColour osuColour { get; set; } = null!; - public LevelBadge() { TooltipText = UsersStrings.ShowStatsLevel("0"); @@ -91,7 +88,7 @@ namespace osu.Game.Overlays.Profile.Header.Components tier = RankingTier.Lustrous; } - return osuColour.ForRankingTier(tier); + return OsuColour.ForRankingTier(tier); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs index 3d97082230..029de96c41 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs @@ -4,12 +4,14 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; 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.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; @@ -22,7 +24,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly Dictionary scoreRankInfos = new Dictionary(); private ProfileValueDisplay medalInfo = null!; private ProfileValueDisplay ppInfo = null!; - private ProfileValueDisplay detailGlobalRank = null!; + private GlobalRankDisplay detailGlobalRank = null!; private ProfileValueDisplay detailCountryRank = null!; private RankGraph rankGraph = null!; @@ -39,7 +41,6 @@ namespace osu.Game.Overlays.Profile.Header.Components AutoSizeAxes = Axes.Y, AutoSizeDuration = 200, AutoSizeEasing = Easing.OutQuint, - Masking = true, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 15), Children = new Drawable[] @@ -63,10 +64,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { new[] { - detailGlobalRank = new ProfileValueDisplay(true) - { - Title = UsersStrings.ShowRankGlobalSimple, - }, + detailGlobalRank = new GlobalRankDisplay(), Empty(), detailCountryRank = new ProfileValueDisplay(true) { @@ -155,25 +153,69 @@ namespace osu.Game.Overlays.Profile.Header.Components { var user = data?.User; - medalInfo.Content = user?.Achievements?.Length.ToString() ?? "0"; - ppInfo.Content = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; + medalInfo.Content.Text = user?.Achievements?.Length.ToString() ?? "0"; + ppInfo.Content.Text = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; + ppInfo.Content.TooltipText = getPPInfoTooltipText(user); foreach (var scoreRankInfo in scoreRankInfos) scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; - detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailGlobalRank.HighestRank.Value = user?.RankHighest; + detailGlobalRank.UserStatistics.Value = user?.Statistics; - var rankHighest = user?.RankHighest; - - detailGlobalRank.ContentTooltipText = rankHighest != null - ? UsersStrings.ShowRankHighest(rankHighest.Rank.ToLocalisableString("\\##,##0"), rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")) - : string.Empty; - - detailCountryRank.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailCountryRank.Content.Text = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailCountryRank.Content.TooltipText = getCountryRankTooltipText(user); rankGraph.Statistics.Value = user?.Statistics; } + private static LocalisableString getCountryRankTooltipText(APIUser? user) + { + var variants = user?.Statistics?.Variants; + + LocalisableString? result = null; + + if (variants?.Count > 0) + { + foreach (var variant in variants) + { + if (variant.CountryRank != null) + { + var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.CountryRank.ToLocalisableString("\\##,##0")}"); + + if (result == null) + result = variantText; + else + result = LocalisableString.Interpolate($"{result}\n{variantText}"); + } + } + } + + return result ?? default; + } + + private static LocalisableString getPPInfoTooltipText(APIUser? user) + { + var variants = user?.Statistics?.Variants; + + LocalisableString? result = null; + + if (variants?.Count > 0) + { + foreach (var variant in variants) + { + var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.PP.ToLocalisableString("#,##0")}"); + + if (result == null) + result = variantText; + else + result = LocalisableString.Interpolate($"{result}\n{variantText}"); + } + } + + return result ?? default; + } + private partial class ScoreRankInfo : CompositeDrawable { private readonly OsuSpriteText rankCount; diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs index dce5c84d12..1cd09566fb 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs @@ -85,7 +85,6 @@ namespace osu.Game.Overlays.Profile.Header.Components { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Full, // Prevents the tooltip of having a sudden size reduction and flickering when the text is being faded out. // Also prevents a potential OnHover/HoverLost feedback loop. AlwaysPresent = true, diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs index b2c23458b1..db384ed9d7 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs @@ -14,22 +14,13 @@ namespace osu.Game.Overlays.Profile.Header.Components public partial class ProfileValueDisplay : CompositeDrawable { private readonly OsuSpriteText title; - private readonly ContentText content; public LocalisableString Title { set => title.Text = value; } - public LocalisableString Content - { - set => content.Text = value; - } - - public LocalisableString ContentTooltipText - { - set => content.TooltipText = value; - } + public ContentText Content { get; } public ProfileValueDisplay(bool big = false, int minimumWidth = 60) { @@ -44,9 +35,9 @@ namespace osu.Game.Overlays.Profile.Header.Components { Font = OsuFont.GetFont(size: 12) }, - content = new ContentText + Content = new ContentText { - Font = OsuFont.GetFont(size: big ? 30 : 20, weight: FontWeight.Light), + Font = OsuFont.GetFont(size: big ? 30 : 20, weight: big ? FontWeight.Regular : FontWeight.Light), }, new Container // Add a minimum size to the FillFlowContainer { @@ -60,10 +51,10 @@ namespace osu.Game.Overlays.Profile.Header.Components private void load(OverlayColourProvider colourProvider) { title.Colour = colourProvider.Content1; - content.Colour = colourProvider.Content2; + Content.Colour = colourProvider.Content2; } - private partial class ContentText : OsuSpriteText, IHasTooltip + public partial class ContentText : OsuSpriteText, IHasTooltip { public LocalisableString TooltipText { get; set; } } diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index 92e2017659..74abb0af2a 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -11,6 +11,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components @@ -87,7 +88,8 @@ namespace osu.Game.Overlays.Profile.Header.Components { background.Colour = colours.Pink; - Action = () => game?.OpenUrlExternally(@"/home/support"); + // Easy to accidentally click so let's always show the open URL popup. + Action = () => game?.OpenUrlExternally(@"/home/support", LinkWarnMode.AlwaysWarn); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs index a3c22d61d2..3cc7bc15e8 100644 --- a/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs @@ -26,7 +26,7 @@ namespace osu.Game.Overlays.Profile.Header.Components InternalChild = info = new ProfileValueDisplay(minimumWidth: 140) { Title = UsersStrings.ShowStatsPlayTime, - ContentTooltipText = "0 hours", + Content = { TooltipText = "0 hours", } }; User.BindValueChanged(updateTime, true); @@ -35,8 +35,8 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateTime(ValueChangedEvent user) { int? playTime = user.NewValue?.User.Statistics?.PlayTime; - info.ContentTooltipText = (playTime ?? 0) / 3600 + " hours"; - info.Content = formatTime(playTime); + info.Content.TooltipText = (playTime ?? 0) / 3600 + " hours"; + info.Content.Text = formatTime(playTime); } private string formatTime(int? secondsNull) diff --git a/osu.Game/Overlays/Profile/Header/Components/UserActionsButton.cs b/osu.Game/Overlays/Profile/Header/Components/UserActionsButton.cs new file mode 100644 index 0000000000..1a2593cff7 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/UserActionsButton.cs @@ -0,0 +1,211 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class UserActionsButton : OsuHoverContainer, IHasPopover + { + public readonly Bindable User = new Bindable(); + + private Box background = null!; + + protected override IEnumerable EffectTargets => [background]; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background1; + + Size = new Vector2(40); + Masking = true; + CornerRadius = 20; + + Child = new CircularContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new SpriteIcon + { + Size = new Vector2(12), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.EllipsisV, + }, + } + }; + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + User.BindValueChanged(_ => Alpha = User.Value?.User.OnlineID == api.LocalUser.Value.OnlineID ? 0 : 1, true); + } + + public Popover GetPopover() => new UserActionPopover(User.Value!.User); + + private partial class UserActionPopover : OsuPopover + { + private readonly APIUser user; + + public UserActionPopover(APIUser user) + : base(false) + { + this.user = user; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, IAPIProvider api, IDialogOverlay? dialogOverlay) + { + Background.Colour = colourProvider.Background6; + + bool userBlocked = api.LocalUserState.Blocks.Any(b => b.TargetID == user.Id); + + AllowableAnchors = [Anchor.BottomCentre, Anchor.TopCentre]; + + Child = new FillFlowContainer + { + Width = 160, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, + Children = new Drawable[] + { + new UserAction(FontAwesome.Solid.Ban, userBlocked ? UsersStrings.BlocksButtonUnblock : UsersStrings.BlocksButtonBlock) + { + Action = () => + { + dialogOverlay?.Push(userBlocked ? ConfirmBlockActionDialog.Unblock(user) : ConfirmBlockActionDialog.Block(user)); + this.HidePopover(); + } + } + } + }; + } + } + + private partial class UserAction : OsuClickableContainer + { + private readonly IconUsage icon; + private readonly LocalisableString caption; + + private Box background = null!; + private CircularContainer indicator = null!; + + public UserAction(IconUsage icon, LocalisableString caption) + { + this.icon = icon; + this.caption = caption; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X; + AutoSizeAxes = Content.AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 4; + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + Alpha = 0, + }, + indicator = new Circle + { + Width = 4, + Height = 14, + X = 10, + Colour = colourProvider.Highlight1, + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Alpha = 0, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 25, Vertical = 5 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new SpriteIcon + { + Icon = icon, + Size = new Vector2(11), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new OsuSpriteText + { + Text = caption, + Font = OsuFont.Style.Body, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + } + } + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + background.Alpha = indicator.Alpha = IsHovered ? 1 : 0; + } + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 165a576c03..3d9539ce1f 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -44,6 +44,8 @@ namespace osu.Game.Overlays.Profile.Header private UpdateableFlag userFlag = null!; private OsuHoverContainer userCountryContainer = null!; private OsuSpriteText userCountryText = null!; + private UpdateableTeamFlag teamFlag = null!; + private OsuSpriteText teamText = null!; private GroupBadgeFlow groupBadgeFlow = null!; private ToggleCoverButton coverToggle = null!; private PreviousUsernamesDisplay previousUsernamesDisplay = null!; @@ -154,29 +156,58 @@ namespace osu.Game.Overlays.Profile.Header titleText = new OsuSpriteText { Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), - Margin = new MarginPadding { Bottom = 5 } + Margin = new MarginPadding { Bottom = 3 }, }, new FillFlowContainer { + Margin = new MarginPadding { Top = 3 }, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), Children = new Drawable[] { - userFlag = new UpdateableFlag - { - Size = new Vector2(28, 20), - }, - userCountryContainer = new OsuHoverContainer + new FillFlowContainer { AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Left = 5 }, - Child = userCountryText = new OsuSpriteText + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] { - Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), - }, + userFlag = new UpdateableFlag + { + Size = new Vector2(28, 20), + }, + userCountryContainer = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Child = userCountryText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + }, + }, + } }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] + { + teamFlag = new UpdateableTeamFlag + { + Size = new Vector2(40, 20), + }, + teamText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + }, + } + } } }, } @@ -213,10 +244,12 @@ namespace osu.Game.Overlays.Profile.Header cover.User = user; avatar.User = user; usernameText.Text = user?.Username ?? string.Empty; - openUserExternally.Link = $@"{api.WebsiteRootUrl}/users/{user?.Id ?? 0}"; + openUserExternally.Link = $@"{api.Endpoints.WebsiteUrl}/users/{user?.Id ?? 0}"; userFlag.CountryCode = user?.CountryCode ?? default; userCountryText.Text = (user?.CountryCode ?? default).GetDescription(); userCountryContainer.Action = () => rankingsOverlay?.ShowCountry(user?.CountryCode ?? default); + teamFlag.Team = user?.Team; + teamText.Text = user?.Team?.Name ?? string.Empty; supporterTag.SupportLevel = user?.SupportLevel ?? 0; titleText.Text = user?.Title ?? string.Empty; titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index 1c94048758..0afc20d66d 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -116,7 +116,7 @@ namespace osu.Game.Overlays.Profile.Sections CurrentPage = CurrentPage?.TakeNext(ItemsPerPage) ?? new PaginationParameters(InitialItemsCount); - retrievalRequest = CreateRequest(User.Value, CurrentPage.Value); + retrievalRequest = CreateRequest(User.Value, new PaginationParameters(CurrentPage.Value.Offset, CurrentPage.Value.Limit + 1)); retrievalRequest.Success += items => UpdateItems(items, loadCancellation); api.Queue(retrievalRequest); @@ -124,8 +124,6 @@ namespace osu.Game.Overlays.Profile.Sections protected virtual void UpdateItems(List items, CancellationTokenSource cancellationTokenSource) => Schedule(() => { - OnItemsReceived(items); - if (!items.Any() && CurrentPage?.Offset == 0) { moreButton.Hide(); @@ -137,11 +135,18 @@ namespace osu.Game.Overlays.Profile.Sections return; } + bool hasMore = items.Count > CurrentPage?.Limit; + + if (hasMore) + items.RemoveAt(items.Count - 1); + + OnItemsReceived(items); + LoadComponentsAsync(items.Select(CreateDrawableItem).Where(d => d != null).Cast(), drawables => { missing.Hide(); - moreButton.FadeTo(items.Count == CurrentPage?.Limit ? 1 : 0); + moreButton.FadeTo(hasMore ? 1 : 0); moreButton.IsLoading = false; ItemsContainer.AddRange(drawables); diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 63afca8b74..247faaeabf 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -216,34 +217,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { var font = OsuFont.GetFont(weight: FontWeight.Bold); - if (Score.PP.HasValue) - { - return new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = font, - Text = $"{Score.PP:0}", - Colour = colourProvider.Highlight1 - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = font.With(size: 12), - Text = "pp", - Colour = colourProvider.Light3 - } - } - }; - } - + // cross-reference: https://github.com/ppy/osu-web/blob/a6afee076f4f68bb56dea0cb8f18db63651763a7/resources/js/profile-page/play-detail.tsx#L118-L133 if (Score.Beatmap?.Status.GrantsPerformancePoints() != true) { if (Score.Beatmap?.Status == BeatmapOnlineStatus.Loved) @@ -266,7 +240,8 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks }; } - if (!Score.Ranked) + // cross-reference: https://github.com/ppy/osu-web/blob/a6afee076f4f68bb56dea0cb8f18db63651763a7/resources/js/scores/pp-value.tsx#L19-L39 + if (!Score.Ranked || !Score.Preserve || (Score.PP == null && Score.Processed)) { return new SpriteTextWithTooltip { @@ -277,12 +252,44 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks }; } - return new SpriteIconWithTooltip + if (Score.PP == null) { - Icon = FontAwesome.Solid.Sync, - Size = new Vector2(font.Size), - TooltipText = ScoresStrings.StatusProcessing, - Colour = colourProvider.Highlight1 + return new SpriteIconWithTooltip + { + Icon = FontAwesome.Solid.Sync, + Size = new Vector2(font.Size), + TooltipText = ScoresStrings.StatusProcessing, + Colour = colourProvider.Highlight1 + }; + } + + var ppTooltipText = LocalisableString.Interpolate($@"{Score.PP:N1}pp"); + + return new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new[] + { + new SpriteTextWithTooltip + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = font, + Text = Score.PP.ToLocalisableString(@"N0"), + TooltipText = ppTooltipText, + Colour = colourProvider.Highlight1, + }, + new SpriteTextWithTooltip + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = font.With(size: 12), + Text = @"pp", + TooltipText = ppTooltipText, + Colour = colourProvider.Light3, + } + } }; } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index 6cfe34ec6f..36b20d0be5 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -4,6 +4,7 @@ using osu.Framework.Extensions.LocalisationExtensions; 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.Online.API.Requests.Responses; @@ -44,7 +45,9 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Child = new OsuSpriteText { Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = Score.PP.HasValue ? $"{Score.PP * weight:0}pp" : string.Empty, + Text = Score.PP.HasValue + ? LocalisableString.Interpolate($"{Score.PP * weight:N0}pp") + : string.Empty, }, } } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index 8a0003b4ea..05762f29f9 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -223,7 +223,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent private void addBeatmapsetLink() => content.AddLink(activity.Beatmapset.AsNonNull().Title, LinkAction.OpenBeatmapSet, getLinkArgument(activity.Beatmapset.AsNonNull().Url), creationParameters: t => t.Font = getLinkFont()); - private object getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.WebsiteRootUrl}{url}").Argument.AsNonNull(); + private object getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.Endpoints.WebsiteUrl}{url}").Argument.AsNonNull(); private FontUsage getLinkFont(FontWeight fontWeight = FontWeight.Regular) => OsuFont.GetFont(size: font_size, weight: fontWeight, italics: true); diff --git a/osu.Game/Overlays/Rankings/RankingsScope.cs b/osu.Game/Overlays/Rankings/RankingsScope.cs index 0740c17e8c..658732a1b1 100644 --- a/osu.Game/Overlays/Rankings/RankingsScope.cs +++ b/osu.Game/Overlays/Rankings/RankingsScope.cs @@ -8,10 +8,10 @@ namespace osu.Game.Overlays.Rankings { public enum RankingsScope { - [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypePerformance))] + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatPerformance))] Performance, - [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeScore))] + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatRankedScore))] Score, [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCountry))] diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index fb3e58d2ac..733aa7ca54 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected override CountryCode GetCountryCode(CountryStatistics item) => item.Code; - protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Code); + protected override Drawable[] CreateFlagContent(CountryStatistics item) => [new CountryName(item.Code)]; protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index b9f7e443ca..f4ed41800a 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected abstract CountryCode GetCountryCode(TModel item); - protected abstract Drawable CreateFlagContent(TModel item); + protected abstract Drawable[] CreateFlagContent(TModel item); private OsuSpriteText createIndexDrawable(int index) => new RowText { @@ -92,16 +92,13 @@ namespace osu.Game.Overlays.Rankings.Tables { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), + Spacing = new Vector2(5, 0), Margin = new MarginPadding { Bottom = row_spacing }, - Children = new[] - { - new UpdateableFlag(GetCountryCode(item)) - { - Size = new Vector2(28, 20), - }, - CreateFlagContent(item) - } + Children = + [ + new UpdateableFlag(GetCountryCode(item)) { Size = new Vector2(28, 20) }, + ..CreateFlagContent(item) + ] }; protected class RankingsTableColumn : TableColumn diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index 4d25065578..c651108ec3 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -14,6 +14,8 @@ using osu.Game.Users; using osu.Game.Scoring; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; +using osu.Game.Users.Drawables; +using osuTK; namespace osu.Game.Overlays.Rankings.Tables { @@ -61,7 +63,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected sealed override CountryCode GetCountryCode(UserStatistics item) => item.User.CountryCode; - protected sealed override Drawable CreateFlagContent(UserStatistics item) + protected sealed override Drawable[] CreateFlagContent(UserStatistics item) { var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { @@ -70,7 +72,7 @@ namespace osu.Game.Overlays.Rankings.Tables TextAnchor = Anchor.CentreLeft }; username.AddUserLink(item.User); - return username; + return [new UpdateableTeamFlag(item.User.Team) { Size = new Vector2(40, 20) }, username]; } protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[] diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index a71f2a6d29..811f6b606a 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.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. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using System.Collections.Generic; using System.Linq; +using osu.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; @@ -19,9 +19,11 @@ namespace osu.Game.Overlays.Settings.Sections.Audio protected override LocalisableString Header => AudioSettingsStrings.AudioDevicesHeader; [Resolved] - private AudioManager audio { get; set; } + private AudioManager audio { get; set; } = null!; - private SettingsDropdown dropdown; + private SettingsDropdown dropdown = null!; + + private SettingsCheckbox? wasapiExperimental; [BackgroundDependencyLoader] private void load() @@ -32,17 +34,41 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { LabelText = AudioSettingsStrings.OutputDevice, Keywords = new[] { "speaker", "headphone", "output" } - } + }, }; - updateItems(); + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + { + Add(wasapiExperimental = new SettingsCheckbox + { + LabelText = AudioSettingsStrings.WasapiLabel, + TooltipText = AudioSettingsStrings.WasapiTooltip, + Current = audio.UseExperimentalWasapi, + Keywords = new[] { "wasapi", "latency", "exclusive" } + }); + + wasapiExperimental.Current.ValueChanged += _ => onDeviceChanged(string.Empty); + } audio.OnNewDevice += onDeviceChanged; audio.OnLostDevice += onDeviceChanged; dropdown.Current = audio.AudioDevice; + + onDeviceChanged(string.Empty); } - private void onDeviceChanged(string name) => updateItems(); + private void onDeviceChanged(string _) + { + updateItems(); + + if (wasapiExperimental != null) + { + if (wasapiExperimental.Current.Value) + wasapiExperimental.SetNoticeText(AudioSettingsStrings.WasapiNotice, true); + else + wasapiExperimental.ClearNoticeText(); + } + } private void updateItems() { @@ -61,7 +87,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio // functionality would require involved OS-specific code. dropdown.Items = deviceItems // Dropdown doesn't like null items. Somehow we are seeing some arrive here (see https://github.com/ppy/osu/issues/21271) - .Where(i => i != null) + .Where(i => i.IsNotNull()) .Distinct() .ToList(); } @@ -70,7 +96,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { base.Dispose(isDisposing); - if (audio != null) + if (audio.IsNotNull()) { audio.OnNewDevice -= onDeviceChanged; audio.OnLostDevice -= onDeviceChanged; diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs index b9f043a233..6e5e010518 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs @@ -8,13 +8,13 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -109,6 +109,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio base.LoadComplete(); averageHitErrorHistory.BindCollectionChanged(updateDisplay, true); + current.BindValueChanged(_ => updateHintText()); SuggestedOffset.BindValueChanged(_ => updateHintText(), true); } @@ -148,17 +149,28 @@ namespace osu.Game.Overlays.Settings.Sections.Audio break; } - SuggestedOffset.Value = averageHitErrorHistory.Any() ? averageHitErrorHistory.Average(dataPoint => dataPoint.SuggestedGlobalAudioOffset) : null; + SuggestedOffset.Value = averageHitErrorHistory.Any() ? Math.Round(averageHitErrorHistory.Average(dataPoint => dataPoint.SuggestedGlobalAudioOffset)) : null; } private float getXPositionForOffset(double offset) => (float)(Math.Clamp(offset, current.MinValue, current.MaxValue) / (2 * current.MaxValue)); private void updateHintText() { - hintText.Text = SuggestedOffset.Value == null - ? AudioSettingsStrings.SuggestedOffsetNote - : AudioSettingsStrings.SuggestedOffsetValueReceived(averageHitErrorHistory.Count, SuggestedOffset.Value.ToLocalisableString(@"N0")); - applySuggestion.Enabled.Value = SuggestedOffset.Value != null; + if (SuggestedOffset.Value == null) + { + applySuggestion.Enabled.Value = false; + hintText.Text = AudioSettingsStrings.SuggestedOffsetNote; + } + else if (Math.Abs(SuggestedOffset.Value.Value - current.Value) < 1) + { + applySuggestion.Enabled.Value = false; + hintText.Text = AudioSettingsStrings.SuggestedOffsetCorrect(averageHitErrorHistory.Count); + } + else + { + applySuggestion.Enabled.Value = true; + hintText.Text = AudioSettingsStrings.SuggestedOffsetValueReceived(averageHitErrorHistory.Count, SuggestedOffset.Value.Value.ToStandardFormattedString(0)); + } } private partial class OffsetSliderBar : RoundedSliderBar diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index e05d20a5db..b839c98f9f 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -26,6 +26,12 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { Current = config.GetBindable(OsuSetting.AudioOffset), }, + new SettingsCheckbox + { + LabelText = AudioSettingsStrings.AdjustBeatmapOffsetAutomatically, + TooltipText = AudioSettingsStrings.AdjustBeatmapOffsetAutomaticallyTooltip, + Current = config.GetBindable(OsuSetting.AutomaticallyAdjustBeatmapOffset), + } }; } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index 1d2129413c..969e65e823 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.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.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -20,12 +21,13 @@ namespace osu.Game.Overlays.Settings.Sections public DebugSection() { - Children = new Drawable[] + if (DebugUtils.IsDebugBuild) { - new GeneralSettings(), - new BatchImportSettings(), - new MemorySettings(), - }; + Add(new GeneralSettings()); + Add(new BatchImportSettings()); + } + + Add(new MemorySettings()); } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs index bd6ada4ca7..914fc9d141 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs @@ -3,7 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; -using osu.Framework.Graphics; using osu.Framework.Localisation; namespace osu.Game.Overlays.Settings.Sections.DebugSettings @@ -15,19 +14,17 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings [BackgroundDependencyLoader] private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig) { - Children = new Drawable[] + Add(new SettingsCheckbox { - new SettingsCheckbox - { - LabelText = @"Show log overlay", - Current = frameworkConfig.GetBindable(FrameworkSetting.ShowLogOverlay) - }, - new SettingsCheckbox - { - LabelText = @"Bypass front-to-back render pass", - Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) - }, - }; + LabelText = @"Show log overlay", + Current = frameworkConfig.GetBindable(FrameworkSetting.ShowLogOverlay) + }); + + Add(new SettingsCheckbox + { + LabelText = @"Bypass front-to-back render pass", + Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) + }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index b693822838..7b9b88a213 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Runtime; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Development; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -24,73 +26,112 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings SettingsButton blockAction; SettingsButton unblockAction; - Children = new Drawable[] + Add(new SettingsButton { - new SettingsButton + Text = @"Clear all caches", + Action = () => { - Text = @"Clear all caches", - Action = host.Collect - }, - new SettingsButton + host.Collect(); + + // host.Collect() uses GCCollectionMode.Optimized, but we should be as aggressive as possible here. + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true); + } + }); + + SettingsEnumDropdown latencyModeDropdown; + Add(latencyModeDropdown = new SettingsEnumDropdown + { + LabelText = "GC mode", + }); + + latencyModeDropdown.Current.BindValueChanged(mode => + { + Logger.Log($"Changing latency mode: {mode.NewValue}"); + + switch (mode.NewValue) { - Text = @"Compact realm", - Action = () => + case GCLatencyMode.Default: + // https://github.com/ppy/osu-framework/blob/1d5301018dfed1a28702be56e1d53c4835b199f2/osu.Framework/Platform/GameHost.cs#L703 + GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency; + break; + + case GCLatencyMode.Interactive: + GCSettings.LatencyMode = System.Runtime.GCLatencyMode.Interactive; + break; + } + }); + + if (DebugUtils.IsDebugBuild) + { + AddRange(new Drawable[] + { + new SettingsButton { - // Blocking operations implicitly causes a Compact(). - using (realm.BlockAllOperations(@"compact")) + Text = @"Compact realm", + Action = () => { + // Blocking operations implicitly causes a Compact(). + using (realm.BlockAllOperations(@"compact")) + { + } + } + }, + blockAction = new SettingsButton + { + Text = @"Block realm", + }, + unblockAction = new SettingsButton + { + Text = @"Unblock realm", + } + }); + + blockAction.Action = () => + { + try + { + IDisposable? token = realm.BlockAllOperations(@"maintenance"); + + blockAction.Enabled.Value = false; + + // As a safety measure, unblock after 10 seconds. + // This is to handle the case where a dev may block, but then something on the update thread + // accesses realm and blocks for eternity. + Task.Factory.StartNew(() => + { + Thread.Sleep(10000); + unblock(); + }); + + unblockAction.Action = unblock; + + void unblock() + { + if (token.IsNull()) + return; + + token.Dispose(); + token = null; + + Scheduler.Add(() => + { + blockAction.Enabled.Value = true; + unblockAction.Action = null; + }); } } - }, - blockAction = new SettingsButton - { - Text = @"Block realm", - }, - unblockAction = new SettingsButton - { - Text = @"Unblock realm", - }, - }; - - blockAction.Action = () => - { - try - { - IDisposable? token = realm.BlockAllOperations(@"maintenance"); - - blockAction.Enabled.Value = false; - - // As a safety measure, unblock after 10 seconds. - // This is to handle the case where a dev may block, but then something on the update thread - // accesses realm and blocks for eternity. - Task.Factory.StartNew(() => + catch (Exception e) { - Thread.Sleep(10000); - unblock(); - }); - - unblockAction.Action = unblock; - - void unblock() - { - if (token.IsNull()) - return; - - token.Dispose(); - token = null; - - Scheduler.Add(() => - { - blockAction.Enabled.Value = true; - unblockAction.Action = null; - }); + Logger.Error(e, @"Blocking realm failed"); } - } - catch (Exception e) - { - Logger.Error(e, @"Blocking realm failed"); - } - }; + }; + } + } + + private enum GCLatencyMode + { + Default, + Interactive, } } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs index 048351b4cb..830ccec279 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs @@ -35,7 +35,8 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay new SettingsCheckbox { LabelText = GameplaySettingsStrings.LightenDuringBreaks, - Current = config.GetBindable(OsuSetting.LightenDuringBreaks) + Current = config.GetBindable(OsuSetting.LightenDuringBreaks), + Keywords = new[] { "dim", "level" } }, new SettingsCheckbox { diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 261103173e..596e4b2589 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; @@ -11,8 +12,10 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Statistics; using osu.Game.Configuration; +using osu.Game.IO; using osu.Game.Localisation; using osu.Game.Online.Multiplayer; +using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Updater; @@ -26,6 +29,9 @@ namespace osu.Game.Overlays.Settings.Sections.General protected override LocalisableString Header => GeneralSettingsStrings.UpdateHeader; private SettingsButton checkForUpdatesButton = null!; + private SettingsEnumDropdown releaseStreamDropdown = null!; + + private readonly Bindable configReleaseStream = new Bindable(); [Resolved] private UpdateManager? updateManager { get; set; } @@ -33,23 +39,47 @@ namespace osu.Game.Overlays.Settings.Sections.General [Resolved] private INotificationOverlay? notifications { get; set; } - [Resolved] - private Storage storage { get; set; } = null!; - [Resolved] private OsuGame? game { get; set; } - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - Add(new SettingsEnumDropdown - { - LabelText = GeneralSettingsStrings.ReleaseStream, - Current = config.GetBindable(OsuSetting.ReleaseStream), - }); + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } - if (updateManager?.CanCheckForUpdate == true) + private Storage exportStorage = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, Storage storage) + { + config.BindWith(OsuSetting.ReleaseStream, configReleaseStream); + + bool isDesktop = RuntimeInfo.IsDesktop; + bool supportsExport = RuntimeInfo.OS != RuntimeInfo.Platform.Android; + bool canCheckUpdates = updateManager?.CanCheckForUpdate == true; + + if (canCheckUpdates) { + // For simplicity, hide the concept of release streams from mobile users. + if (isDesktop) + { + Add(releaseStreamDropdown = new SettingsEnumDropdown + { + LabelText = GeneralSettingsStrings.ReleaseStream, + Current = { Value = configReleaseStream.Value }, + Keywords = new[] { @"version" }, + }); + + if (updateManager!.FixedReleaseStream != null) + { + configReleaseStream.Value = updateManager.FixedReleaseStream.Value; + + releaseStreamDropdown.ShowsDefaultIndicator = false; + releaseStreamDropdown.Items = [updateManager.FixedReleaseStream.Value]; + releaseStreamDropdown.SetNoticeText(GeneralSettingsStrings.ChangeReleaseStreamPackageManagerWarning); + } + + releaseStreamDropdown.Current.BindValueChanged(releaseStreamChanged); + } + Add(checkForUpdatesButton = new SettingsButton { Text = GeneralSettingsStrings.CheckUpdate, @@ -57,7 +87,8 @@ namespace osu.Game.Overlays.Settings.Sections.General }); } - if (RuntimeInfo.IsDesktop) + // Loosely update-related maintenance buttons. + if (isDesktop) { Add(new SettingsButton { @@ -65,20 +96,46 @@ namespace osu.Game.Overlays.Settings.Sections.General Keywords = new[] { @"logs", @"files", @"access", "directory" }, Action = () => storage.PresentExternally(), }); + } + if (supportsExport) + { Add(new SettingsButton { Text = GeneralSettingsStrings.ExportLogs, Keywords = new[] { @"bug", "report", "logs", "files" }, Action = () => Task.Run(exportLogs), }); + } + if (isDesktop) + { Add(new SettingsButton { Text = GeneralSettingsStrings.ChangeFolderLocation, Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) }); } + + exportStorage = (storage as OsuStorage)?.GetExportStorage() ?? storage.GetStorageForDirectory(@"exports"); + } + + private void releaseStreamChanged(ValueChangedEvent stream) + { + if (stream.NewValue == ReleaseStream.Tachyon) + { + dialogOverlay?.Push( + new ConfirmDialog(GeneralSettingsStrings.ChangeReleaseStreamConfirmation, + () => configReleaseStream.Value = ReleaseStream.Tachyon, + () => releaseStreamDropdown.Current.Value = ReleaseStream.Lazer) + { + BodyText = GeneralSettingsStrings.ChangeReleaseStreamConfirmationInfo + }); + + return; + } + + configReleaseStream.Value = stream.NewValue; } private async Task checkForUpdates() @@ -96,7 +153,7 @@ namespace osu.Game.Overlays.Settings.Sections.General try { - bool foundUpdate = await updateManager.CheckForUpdateAsync().ConfigureAwait(true); + bool foundUpdate = await updateManager.CheckForUpdateAsync(checkingNotification.CancellationToken).ConfigureAwait(true); if (!foundUpdate) { @@ -112,8 +169,9 @@ namespace osu.Game.Overlays.Settings.Sections.General } finally { - // This sequence allows the notification to be immediately dismissed. - checkingNotification.State = ProgressNotificationState.Cancelled; + // This sequence allows the notification to be immediately dismissed without posting a continuation message. + checkingNotification.CompletionTarget = null; + checkingNotification.State = ProgressNotificationState.Completed; checkingNotification.Close(false); checkForUpdatesButton.Enabled.Value = true; } @@ -129,7 +187,7 @@ namespace osu.Game.Overlays.Settings.Sections.General notifications?.Post(notification); - const string archive_filename = "exports/compressed-logs.zip"; + const string archive_filename = "compressed-logs.zip"; try { @@ -138,7 +196,7 @@ namespace osu.Game.Overlays.Settings.Sections.General var logStorage = Logger.Storage; - using (var outStream = storage.CreateFileSafely(archive_filename)) + using (var outStream = exportStorage.CreateFileSafely(archive_filename)) using (var zip = ZipArchive.Create()) { foreach (string? f in logStorage.GetFiles(string.Empty, "*.log")) @@ -152,12 +210,12 @@ namespace osu.Game.Overlays.Settings.Sections.General notification.State = ProgressNotificationState.Cancelled; // cleanup if export is failed or canceled. - storage.Delete(archive_filename); + exportStorage.Delete(archive_filename); throw; } notification.CompletionText = "Exported logs! Click to view."; - notification.CompletionClickAction = () => storage.PresentFileExternally(archive_filename); + notification.CompletionClickAction = () => exportStorage.PresentFileExternally(archive_filename); notification.State = ProgressNotificationState.Completed; } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index f40a4c941f..cdc4f328c3 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -36,8 +36,11 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private Bindable scalingMode = null!; private Bindable sizeFullscreen = null!; + private Bindable sizeWindowed = null!; - private readonly BindableList resolutions = new BindableList(new[] { new Size(9999, 9999) }); + private readonly BindableList resolutionsFullscreen = new BindableList(new[] { new Size(9999, 9999) }); + private readonly BindableList resolutionsWindowed = new BindableList(); + private readonly Bindable windowedResolution = new Bindable(); private readonly IBindable fullscreenCapability = new Bindable(FullscreenCapability.Capable); [Resolved] @@ -48,12 +51,15 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private IWindow? window; - private SettingsDropdown resolutionDropdown = null!; + private SettingsDropdown resolutionFullscreenDropdown = null!; + private SettingsDropdown resolutionWindowedDropdown = null!; private SettingsDropdown displayDropdown = null!; private SettingsDropdown windowModeDropdown = null!; private SettingsCheckbox minimiseOnFocusLossCheckbox = null!; private SettingsCheckbox safeAreaConsiderationsCheckbox = null!; + private Bindable windowedPositionX = null!; + private Bindable windowedPositionY = null!; private Bindable scalingPositionX = null!; private Bindable scalingPositionY = null!; private Bindable scalingSizeX = null!; @@ -70,12 +76,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingMode = osuConfig.GetBindable(OsuSetting.Scaling); sizeFullscreen = config.GetBindable(FrameworkSetting.SizeFullscreen); + sizeWindowed = config.GetBindable(FrameworkSetting.WindowedSize); + windowedPositionX = config.GetBindable(FrameworkSetting.WindowedPositionX); + windowedPositionY = config.GetBindable(FrameworkSetting.WindowedPositionY); scalingSizeX = osuConfig.GetBindable(OsuSetting.ScalingSizeX); scalingSizeY = osuConfig.GetBindable(OsuSetting.ScalingSizeY); scalingPositionX = osuConfig.GetBindable(OsuSetting.ScalingPositionX); scalingPositionY = osuConfig.GetBindable(OsuSetting.ScalingPositionY); scalingBackgroundDim = osuConfig.GetBindable(OsuSetting.ScalingBackgroundDim); + windowedResolution.Value = sizeWindowed.Value; + if (window != null) { currentDisplay.BindTo(window.CurrentDisplayBindable); @@ -100,13 +111,20 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics Items = window?.Displays, Current = currentDisplay, }, - resolutionDropdown = new ResolutionSettingsDropdown + resolutionFullscreenDropdown = new ResolutionSettingsDropdown { LabelText = GraphicsSettingsStrings.Resolution, ShowsDefaultIndicator = false, - ItemSource = resolutions, + ItemSource = resolutionsFullscreen, Current = sizeFullscreen }, + resolutionWindowedDropdown = new ResolutionSettingsDropdown + { + LabelText = GraphicsSettingsStrings.Resolution, + ShowsDefaultIndicator = false, + ItemSource = resolutionsWindowed, + Current = windowedResolution + }, minimiseOnFocusLossCheckbox = new SettingsCheckbox { LabelText = GraphicsSettingsStrings.MinimiseOnFocusLoss, @@ -202,19 +220,68 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { if (display.NewValue == null) { - resolutions.Clear(); + resolutionsFullscreen.Clear(); + resolutionsWindowed.Clear(); return; } - resolutions.ReplaceRange(1, resolutions.Count - 1, display.NewValue.DisplayModes - .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) - .OrderByDescending(m => Math.Max(m.Size.Height, m.Size.Width)) - .Select(m => m.Size) - .Distinct()); + var buffer = new Bindable(windowedResolution.Value); + resolutionWindowedDropdown.Current = buffer; + + var fullscreenResolutions = display.NewValue.DisplayModes + .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) + .OrderByDescending(m => Math.Max(m.Size.Height, m.Size.Width)) + .Select(m => m.Size) + .Distinct() + .ToList(); + var windowedResolutions = fullscreenResolutions + .Where(res => res.Width <= display.NewValue.UsableBounds.Width && res.Height <= display.NewValue.UsableBounds.Height) + .ToList(); + + resolutionsFullscreen.ReplaceRange(1, resolutionsFullscreen.Count - 1, fullscreenResolutions); + resolutionsWindowed.ReplaceRange(0, resolutionsWindowed.Count, windowedResolutions); + + resolutionWindowedDropdown.Current = windowedResolution; updateDisplaySettingsVisibility(); }), true); + windowedResolution.BindValueChanged(size => + { + if (size.NewValue == sizeWindowed.Value || windowModeDropdown.Current.Value != WindowMode.Windowed) + return; + + if (window?.WindowState == Framework.Platform.WindowState.Maximised) + { + window.WindowState = Framework.Platform.WindowState.Normal; + } + + // Adjust only for top decorations (assuming system titlebar). + // Bottom/left/right borders are ignored as invisible padding, which don't align with the screen. + var dBounds = currentDisplay.Value.Bounds; + var dUsable = currentDisplay.Value.UsableBounds; + float topBar = host.Window?.BorderSize.Value.Top ?? 0; + + int w = Math.Min(size.NewValue.Width, dUsable.Width); + int h = (int)Math.Min(size.NewValue.Height, dUsable.Height - topBar); + + windowedResolution.Value = new Size(w, h); + sizeWindowed.Value = windowedResolution.Value; + + float adjustedY = Math.Max( + dUsable.Y + (dUsable.Height - h) / 2f, + dUsable.Y + topBar // titlebar adjustment + ); + windowedPositionY.Value = dBounds.Height - h != 0 ? (adjustedY - dBounds.Y) / (dBounds.Height - h) : 0; + windowedPositionX.Value = dBounds.Width - w != 0 ? (dUsable.X - dBounds.X + (dUsable.Width - w) / 2f) / (dBounds.Width - w) : 0; + }); + + sizeWindowed.BindValueChanged(size => + { + if (size.NewValue != windowedResolution.Value) + windowedResolution.Value = size.NewValue; + }); + scalingMode.BindValueChanged(_ => { scalingSettings.ClearTransforms(); @@ -223,8 +290,6 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics updateScalingModeVisibility(); }); - - // initial update bypasses transforms updateScalingModeVisibility(); void updateScalingModeVisibility() @@ -260,7 +325,9 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private void updateDisplaySettingsVisibility() { - resolutionDropdown.CanBeShown.Value = resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen; + resolutionFullscreenDropdown.CanBeShown.Value = windowModeDropdown.Current.Value == WindowMode.Fullscreen && resolutionsFullscreen.Count > 1; + resolutionWindowedDropdown.CanBeShown.Value = windowModeDropdown.Current.Value == WindowMode.Windowed && resolutionsWindowed.Count > 1; + displayDropdown.CanBeShown.Value = displayDropdown.Items.Count() > 1; minimiseOnFocusLossCheckbox.CanBeShown.Value = RuntimeInfo.IsDesktop && windowModeDropdown.Current.Value == WindowMode.Fullscreen; safeAreaConsiderationsCheckbox.CanBeShown.Value = host.Window?.SafeAreaPadding.Value.Total != Vector2.Zero; diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_ConflictResolution.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.ConflictResolution.cs similarity index 100% rename from osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_ConflictResolution.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.ConflictResolution.cs diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.KeyButton.cs similarity index 100% rename from osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow_KeyButton.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.KeyButton.cs diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 6eb512fa35..3fb4016498 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -57,10 +57,11 @@ namespace osu.Game.Overlays.Settings.Sections.Input LabelText = MouseSettingsStrings.HighPrecisionMouse, TooltipText = MouseSettingsStrings.HighPrecisionMouseTooltip, Current = relativeMode, - Keywords = new[] { @"raw", @"input", @"relative", @"cursor" } + Keywords = new[] { @"raw", @"input", @"relative", @"cursor", "sensitivity", "speed", "velocity" }, }, new SensitivitySetting { + Keywords = new[] { "speed", "velocity" }, LabelText = MouseSettingsStrings.CursorSensitivity, Current = localSensitivity }, diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 00ffbc1120..6aebec88a9 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -37,13 +36,15 @@ namespace osu.Game.Overlays.Settings.Sections.Input private readonly Bindable areaSize = new Bindable(); private readonly IBindable tablet = new Bindable(); - private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0 }; - private readonly BindableNumber offsetY = new BindableNumber { MinValue = 0 }; + private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0, Precision = 1 }; + private readonly BindableNumber offsetY = new BindableNumber { MinValue = 0, Precision = 1 }; - private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10 }; - private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10 }; + private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10, Precision = 1 }; + private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10, Precision = 1 }; - private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360 }; + private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360, Precision = 1 }; + + private readonly BindableNumber pressureThreshold = new BindableNumber { MinValue = 0.0f, MaxValue = 1.0f, Precision = 0.005f }; [Resolved] private GameHost host { get; set; } @@ -111,15 +112,12 @@ namespace osu.Game.Overlays.Settings.Sections.Input AutoSizeAxes = Axes.Y, }.With(t => { - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) - { - t.NewLine(); - var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedString(TabletSettingsStrings.NoTabletDetectedDescription( - RuntimeInfo.OS == RuntimeInfo.Platform.Windows - ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" - : @"https://opentabletdriver.net/Wiki/FAQ/Linux"))); - t.AddLinks(formattedSource.Text, formattedSource.Links); - } + t.NewLine(); + + const string url = @"https://opentabletdriver.net/Wiki/FAQ/General"; + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedString(TabletSettingsStrings.NoTabletDetectedDescription(url))); + + t.AddLinks(formattedSource.Text, formattedSource.Links); }), } }, @@ -213,6 +211,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input Current = sizeY, CanBeShown = { BindTarget = enabled } }, + new SettingsPercentageSlider + { + TransferValueOnCommit = true, + LabelText = TabletSettingsStrings.TipPressureForClick, + Current = pressureThreshold, + CanBeShown = { BindTarget = enabled } + }, } }, }; @@ -267,6 +272,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input aspectRatioApplication = Schedule(() => forceAspectRatio(aspect.NewValue)); }); + pressureThreshold.BindTo(tabletHandler.PressureThreshold); + tablet.BindTo(tabletHandler.Tablet); tablet.BindValueChanged(val => Schedule(() => { diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index f75fc2c8bc..47314dcafe 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -1,8 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Localisation; using osu.Game.Screens; @@ -15,22 +19,33 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { protected override LocalisableString Header => CommonStrings.General; + private ISystemFileSelector? selector; + [BackgroundDependencyLoader] - private void load(IPerformFromScreenRunner? performer) + private void load(OsuGameBase game, GameHost host, IPerformFromScreenRunner? performer) { - Children = new[] + if ((selector = host.CreateSystemFileSelector(game.HandledExtensions.ToArray())) != null) + selector.Selected += f => Task.Run(() => game.Import(f.FullName)); + + AddRange(new Drawable[] { new SettingsButton { Text = DebugSettingsStrings.ImportFiles, - Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) + Action = () => + { + if (selector != null) + selector.Present(); + else + performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())); + }, }, new SettingsButton { Text = DebugSettingsStrings.RunLatencyCertifier, Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) } - }; + }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index 7bd0829add..608c6ef1b2 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -29,6 +29,12 @@ namespace osu.Game.Overlays.Settings.Sections.Online Current = config.GetBindable(OsuSetting.NotifyOnPrivateMessage) }, new SettingsCheckbox + { + LabelText = OnlineSettingsStrings.NotifyOnFriendPresenceChange, + TooltipText = OnlineSettingsStrings.NotifyOnFriendPresenceChangeTooltip, + Current = config.GetBindable(OsuSetting.NotifyOnFriendPresenceChange), + }, + new SettingsCheckbox { LabelText = OnlineSettingsStrings.HideCountryFlags, Current = config.GetBindable(OsuSetting.HideCountryFlags) diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 9b04f208a7..2c24a5b277 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -9,18 +9,25 @@ 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.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.SkinEditor; using osu.Game.Screens.Select; using osu.Game.Skinning; +using osuTK; using Realms; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Overlays.Settings.Sections { @@ -64,13 +71,26 @@ namespace osu.Game.Overlays.Settings.Sections Current = skins.CurrentSkinInfo, Keywords = new[] { @"skins" }, }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, + Children = new Drawable[] + { + // This is all super-temporary until we move skin settings to their own panel / overlay. + new RenameSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, + new ExportSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, + new DeleteSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 110 }, + } + }, new SettingsButton { Text = SkinSettingsStrings.SkinLayoutEditor, Action = () => skinEditor?.ToggleVisibility(), }, - new ExportSkinButton(), - new DeleteSkinButton(), }; } @@ -110,6 +130,7 @@ namespace osu.Game.Overlays.Settings.Sections dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_PRO_SKIN).ToLive(realm)); dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm)); dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.CLASSIC_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.RETRO_SKIN).ToLive(realm)); dropdownItems.Add(random_skin_info); @@ -136,6 +157,37 @@ namespace osu.Game.Overlays.Settings.Sections } } + public partial class RenameSkinButton : SettingsButton, IHasPopover + { + [Resolved] + private SkinManager skins { get; set; } + + private Bindable currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = CommonStrings.Rename; + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(_ => updateState()); + currentSkin.BindDisabledChanged(_ => updateState(), true); + } + + private void updateState() => Enabled.Value = !currentSkin.Disabled && currentSkin.Value.SkinInfo.PerformRead(s => !s.Protected); + + public Popover GetPopover() + { + return new RenameSkinPopover(); + } + } + public partial class ExportSkinButton : SettingsButton { [Resolved] @@ -146,7 +198,7 @@ namespace osu.Game.Overlays.Settings.Sections [BackgroundDependencyLoader] private void load() { - Text = SkinSettingsStrings.ExportSkinButton; + Text = CommonStrings.Export; Action = export; } @@ -155,9 +207,12 @@ namespace osu.Game.Overlays.Settings.Sections base.LoadComplete(); currentSkin = skins.CurrentSkin.GetBoundCopy(); - currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + currentSkin.BindValueChanged(_ => updateState()); + currentSkin.BindDisabledChanged(_ => updateState(), true); } + private void updateState() => Enabled.Value = !currentSkin.Disabled && currentSkin.Value.SkinInfo.PerformRead(s => !s.Protected); + private void export() { try @@ -184,7 +239,7 @@ namespace osu.Game.Overlays.Settings.Sections [BackgroundDependencyLoader] private void load() { - Text = SkinSettingsStrings.DeleteSkinButton; + Text = WebCommonStrings.ButtonsDelete; Action = delete; } @@ -193,13 +248,74 @@ namespace osu.Game.Overlays.Settings.Sections base.LoadComplete(); currentSkin = skins.CurrentSkin.GetBoundCopy(); - currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + currentSkin.BindValueChanged(_ => updateState()); + currentSkin.BindDisabledChanged(_ => updateState(), true); } + private void updateState() => Enabled.Value = !currentSkin.Disabled && currentSkin.Value.SkinInfo.PerformRead(s => !s.Protected); + private void delete() { dialogOverlay?.Push(new SkinDeleteDialog(currentSkin.Value)); } } + + public partial class RenameSkinPopover : OsuPopover + { + [Resolved] + private SkinManager skins { get; set; } + + private readonly FocusedTextBox textBox; + + public RenameSkinPopover() + { + AutoSizeAxes = Axes.Both; + Origin = Anchor.TopCentre; + + RoundedButton renameButton; + + Child = new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + Width = 250, + Spacing = new Vector2(10f), + Children = new Drawable[] + { + textBox = new FocusedTextBox + { + PlaceholderText = @"Skin name", + FontSize = OsuFont.DEFAULT_FONT_SIZE, + RelativeSizeAxes = Axes.X, + SelectAllOnFocus = true, + }, + renameButton = new RoundedButton + { + Height = 40, + RelativeSizeAxes = Axes.X, + MatchingFilter = true, + Text = "Save", + } + } + }; + + renameButton.Action += rename; + textBox.OnCommit += (_, _) => rename(); + } + + protected override void PopIn() + { + textBox.Text = skins.CurrentSkinInfo.Value.Value.Name; + textBox.TakeFocus(); + + base.PopIn(); + } + + private void rename() + { + skins.Rename(skins.CurrentSkinInfo.Value, textBox.Text); + PopOut(); + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 5e42c3035c..c50d56b458 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -36,11 +36,13 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface }, new SettingsCheckbox { + Keywords = new[] { "intro", "welcome" }, LabelText = UserInterfaceStrings.InterfaceVoices, Current = config.GetBindable(OsuSetting.MenuVoice) }, new SettingsCheckbox { + Keywords = new[] { "intro", "welcome" }, LabelText = UserInterfaceStrings.OsuMusicTheme, Current = config.GetBindable(OsuSetting.MenuMusic) }, diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index 49bd17dfde..d15008f858 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -19,16 +19,11 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { Children = new Drawable[] { - new SettingsCheckbox - { - ClassicDefault = true, - LabelText = UserInterfaceStrings.RightMouseScroll, - Current = config.GetBindable(OsuSetting.SongSelectRightMouseScroll), - }, new SettingsCheckbox { LabelText = UserInterfaceStrings.ShowConvertedBeatmaps, Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), + Keywords = new[] { "converts", "converted" } }, new SettingsEnumDropdown { diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 3f5d612eb8..196ddca953 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -6,13 +6,12 @@ using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings { - public partial class SettingsButton : RoundedButton, IHasTooltip, IConditionalFilterable + public partial class SettingsButton : RoundedButton, IConditionalFilterable { public SettingsButton() { @@ -25,8 +24,6 @@ namespace osu.Game.Overlays.Settings public BindableBool CanBeShown { get; } = new BindableBool(true); IBindable IConditionalFilterable.CanBeShown => CanBeShown; - public LocalisableString TooltipText { get; set; } - public override IEnumerable FilterTerms { get diff --git a/osu.Game/Overlays/Settings/SettingsFooter.cs b/osu.Game/Overlays/Settings/SettingsFooter.cs index 4e9d4c0d28..f50fca418d 100644 --- a/osu.Game/Overlays/Settings/SettingsFooter.cs +++ b/osu.Game/Overlays/Settings/SettingsFooter.cs @@ -5,6 +5,8 @@ using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Logging; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -76,13 +78,16 @@ namespace osu.Game.Overlays.Settings } } - private partial class BuildDisplay : OsuAnimatedButton + private partial class BuildDisplay : OsuAnimatedButton, IHasContextMenu { private readonly string version; [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private OsuGame? game { get; set; } + public BuildDisplay(string version) { this.version = version; @@ -95,7 +100,7 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load(ChangelogOverlay? changelog) { - Action = () => changelog?.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version); + Action = () => changelog?.ShowBuild(version); Add(new OsuSpriteText { @@ -108,6 +113,11 @@ namespace osu.Game.Overlays.Settings Colour = DebugUtils.IsDebugBuild ? colours.Red : Color4.White, }); } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Copy version", MenuItemType.Standard, () => game?.CopyToClipboard(version)) + }; } } } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 9c6bb5ae60..9186734641 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -45,7 +45,18 @@ namespace osu.Game.Overlays.Settings private OsuTextFlowContainer noticeText; - public bool ShowsDefaultIndicator = true; + private bool showsDefaultIndicator = true; + + public bool ShowsDefaultIndicator + { + get => showsDefaultIndicator; + set + { + showsDefaultIndicator = value; + defaultValueIndicatorContainer.Alpha = value ? 1 : 0; + } + } + private readonly Container defaultValueIndicatorContainer; public LocalisableString TooltipText { get; set; } @@ -214,17 +225,14 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load() { - // intentionally done before LoadComplete to avoid overhead. - if (ShowsDefaultIndicator) + defaultValueIndicatorContainer.Child = new RevertToDefaultButton { - defaultValueIndicatorContainer.Add(new RevertToDefaultButton - { - Current = controlWithCurrent.Current, - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }); - updateLayout(); - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = controlWithCurrent.Current, + }; + + updateLayout(); } private void updateLayout() diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index fbcdb4a968..2548f3c87b 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -5,6 +5,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; namespace osu.Game.Overlays.Settings { @@ -66,7 +67,10 @@ namespace osu.Game.Overlays.Settings private partial class OutlinedNumberBox : OutlinedTextBox { - protected override bool AllowIme => false; + public OutlinedNumberBox() + { + InputProperties = new TextInputProperties(TextInputType.Number, false); + } protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); diff --git a/osu.Game/Overlays/Settings/SettingsSidebar.cs b/osu.Game/Overlays/Settings/SettingsSidebar.cs index d24c0a778c..bac8013a33 100644 --- a/osu.Game/Overlays/Settings/SettingsSidebar.cs +++ b/osu.Game/Overlays/Settings/SettingsSidebar.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -11,6 +12,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osuTK; namespace osu.Game.Overlays.Settings @@ -94,7 +96,7 @@ namespace osu.Game.Overlays.Settings Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), - Text = @"back", + Text = CommonStrings.Back.ToLower(), }, } } diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 1157860e03..3065a4d1bd 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -7,13 +7,13 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Graphics; +using osu.Game.Graphics.Cursor; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections; @@ -30,7 +30,7 @@ namespace osu.Game.Overlays protected override IEnumerable CreateSections() { - var sections = new List + return new List { // This list should be kept in sync with ScreenBehaviour. new GeneralSection(), @@ -43,12 +43,8 @@ namespace osu.Game.Overlays new GraphicsSection(), new OnlineSection(), new MaintenanceSection(), + new DebugSection() }; - - if (DebugUtils.IsDebugBuild) - sections.Add(new DebugSection()); - - return sections; } private readonly List subPanels = new List(); @@ -56,7 +52,13 @@ namespace osu.Game.Overlays private SettingsSubPanel lastOpenedSubPanel; protected override Drawable CreateHeader() => new SettingsHeader(Title, Description); - protected override Drawable CreateFooter() => new SettingsFooter(); + + protected override Drawable CreateFooter() => new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SettingsFooter() + }; public SettingsOverlay() : base(false) @@ -68,6 +70,9 @@ namespace osu.Game.Overlays public void ShowAtControl() where T : Drawable { + // if search isn't cleared then the target control won't be visible if it doesn't match the query + SearchTextBox.Current.SetDefault(); + Show(); // wait for load of sections diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index df50e0f339..9b268c573f 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays public SettingsSectionsContainer SectionsContainer { get; private set; } - private SeekLimitedSearchTextBox searchTextBox; + protected SeekLimitedSearchTextBox SearchTextBox { get; private set; } protected override string PopInSampleName => "UI/settings-pop-in"; protected override double PopInOutSampleBalance => -OsuGameBase.SFX_STEREO_STRENGTH; @@ -135,7 +135,7 @@ namespace osu.Game.Overlays }, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Child = searchTextBox = new SettingsSearchTextBox + Child = SearchTextBox = new SettingsSearchTextBox { RelativeSizeAxes = Axes.X, Origin = Anchor.TopCentre, @@ -183,8 +183,8 @@ namespace osu.Game.Overlays Sidebar?.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); this.FadeTo(1, TRANSITION_LENGTH / 2, Easing.OutQuint); - searchTextBox.TakeFocus(); - searchTextBox.HoldFocus = true; + SearchTextBox.TakeFocus(); + SearchTextBox.HoldFocus = true; } protected virtual float ExpandedPosition => 0; @@ -199,8 +199,8 @@ namespace osu.Game.Overlays Sidebar?.MoveToX(-sidebar_width, TRANSITION_LENGTH, Easing.OutQuint); this.FadeTo(0, TRANSITION_LENGTH / 2, Easing.OutQuint); - searchTextBox.HoldFocus = false; - if (searchTextBox.HasFocus) + SearchTextBox.HoldFocus = false; + if (SearchTextBox.HasFocus) GetContainingFocusManager()!.ChangeFocus(null); } @@ -208,7 +208,7 @@ namespace osu.Game.Overlays protected override void OnFocus(FocusEvent e) { - searchTextBox.TakeFocus(); + SearchTextBox.TakeFocus(); base.OnFocus(e); } @@ -234,7 +234,7 @@ namespace osu.Game.Overlays loading.Hide(); - searchTextBox.Current.BindValueChanged(term => SectionsContainer.SearchTerm = term.NewValue, true); + SearchTextBox.Current.BindValueChanged(term => SectionsContainer.SearchTerm = term.NewValue, true); loadSidebarButtons(); }); diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index f8cf218564..d82118fa1a 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -3,13 +3,15 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Caching; +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.Input; using osu.Framework.Input.Events; -using osu.Framework.Layout; +using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -21,15 +23,13 @@ namespace osu.Game.Overlays { public partial class SettingsToolboxGroup : Container, IExpandable { - private readonly string title; + private readonly LocalisableString title; public const int CONTAINER_WIDTH = 270; private const float transition_duration = 250; private const int header_height = 30; private const int corner_radius = 5; - private readonly Cached headerTextVisibilityCache = new Cached(); - protected override Container Content => content; private readonly FillFlowContainer content = new FillFlowContainer @@ -54,11 +54,15 @@ namespace osu.Game.Overlays private IconButton expandButton = null!; + private InputManager inputManager = null!; + + private Drawable? draggedChild; + /// /// Create a new instance. /// /// The title to be displayed in the header of this group. - public SettingsToolboxGroup(string title) + public SettingsToolboxGroup(LocalisableString title) { this.title = title; @@ -100,7 +104,7 @@ namespace osu.Game.Overlays { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Text = title.ToUpperInvariant(), + Text = title.ToUpper(), Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), Padding = new MarginPadding { Left = 10, Right = 30 }, }, @@ -125,6 +129,8 @@ namespace osu.Game.Overlays { base.LoadComplete(); + inputManager = GetContainingInputManager()!; + Expanded.BindValueChanged(_ => updateExpandedState(true)); updateExpandedState(false); @@ -149,30 +155,31 @@ namespace osu.Game.Overlays { base.Update(); - if (!headerTextVisibilityCache.IsValid) + // These toolbox grouped may be contracted to only show icons. + // For now, let's hide the header to avoid text truncation weirdness in such cases. + headerText.Alpha = (float)Interpolation.DampContinuously(headerText.Alpha, headerText.DrawWidth < DrawWidth ? 1 : 0, 40, Time.Elapsed); + + // Dragged child finished its drag operation. + if (draggedChild != null && inputManager.DraggedDrawable != draggedChild) { - // These toolbox grouped may be contracted to only show icons. - // For now, let's hide the header to avoid text truncation weirdness in such cases. - headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint); - headerTextVisibilityCache.Validate(); + draggedChild = null; + updateExpandedState(true); } } - protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) - { - if (invalidation.HasFlag(Invalidation.DrawSize)) - headerTextVisibilityCache.Invalidate(); - - return base.OnInvalidate(invalidation, source); - } - private void updateExpandedState(bool animate) { + // before we collapse down, let's double check the user is not dragging a UI control contained within us. + if (inputManager.DraggedDrawable.IsRootedAt(this)) + { + draggedChild = inputManager.DraggedDrawable; + } + // clearing transforms is necessary to avoid a previous height transform // potentially continuing to get processed while content has changed to autosize. content.ClearTransforms(); - if (Expanded.Value || IsHovered) + if (Expanded.Value || IsHovered || draggedChild != null) { content.AutoSizeAxes = Axes.Y; content.AutoSizeDuration = animate ? transition_duration : 0; diff --git a/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs new file mode 100644 index 0000000000..4d91b4ebfd --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/ExternalEditOverlay.cs @@ -0,0 +1,309 @@ +// Copyright (c) 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.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.SkinEditor +{ + public partial class ExternalEditOverlay : OsuFocusedOverlayContainer + { + private const double transition_duration = 300; + private FillFlowContainer flow = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + [Resolved] + private SkinManager skinManager { get; set; } = null!; + + private ExternalEditOperation? editOperation; + private TaskCompletionSource? taskCompletionSource; + private bool finishingEdit; + + protected override bool DimMainContent => false; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + // Since we're drawing this overlay on top of another overlay (SkinEditor), the dimming effect isn't applied. So we need to add a dimming effect manually. + new Box + { + Colour = Color4.Black.Opacity(0.5f), + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Masking = true, + CornerRadius = 20, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 500, + AutoSizeEasing = Easing.OutQuint, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + flow = new FillFlowContainer + { + Margin = new MarginPadding(20), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Spacing = new Vector2(15), + } + } + } + } + }; + + gameHost.ExitRequested += tryFinishOnExit; + } + + public async Task Begin(SkinInfo skinInfo) + { + if (taskCompletionSource != null) + throw new InvalidOperationException("Cannot start multiple concurrent external edits!"); + + Show(); + showSpinner("Mounting external skin..."); + setGlobalSkinDisabled(true); + + await Task.Delay(500).ConfigureAwait(true); + + try + { + editOperation = await skinManager.BeginExternalEditing(skinInfo).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log($"Failed to initialize external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); + setGlobalSkinDisabled(false); + Schedule(() => showSpinner("Export failed!")); + Scheduler.AddDelayed(Hide, 1000); + return Task.FromException(ex); + } + + Schedule(() => + { + flow.Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Skin is mounted externally", + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new OsuTextFlowContainer + { + Padding = new MarginPadding(5), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 350, + AutoSizeAxes = Axes.Y, + Text = "Any changes made to the exported folder will be imported to the game, including file additions, modifications and deletions.", + }, + new PurpleRoundedButton + { + Text = "Open folder", + Width = 350, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = openDirectory, + Enabled = { Value = false } + }, + new DangerousRoundedButton + { + Text = EditorStrings.FinishEditingExternally, + Width = 350, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = () => finish().FireAndForget(), + Enabled = { Value = false } + } + }; + }); + + Scheduler.AddDelayed(() => + { + foreach (var b in flow.ChildrenOfType()) + b.Enabled.Value = true; + openDirectory(); + }, 1000); + return (taskCompletionSource = new TaskCompletionSource()).Task; + } + + private void openDirectory() + { + if (editOperation == null) + return; + + gameHost.OpenFileExternally(editOperation.MountedPath.TrimDirectorySeparator() + Path.DirectorySeparatorChar); + } + + private void tryFinishOnExit() + { + if (editOperation != null && !finishingEdit) + finish().FireAndForget(onSuccess: () => Schedule(() => finishingEdit = false)); + } + + private async Task finish() + { + if (finishingEdit) + return; + + finishingEdit = true; + + Debug.Assert(taskCompletionSource != null); + + showSpinner("Cleaning up..."); + await Task.Delay(500).ConfigureAwait(true); + + try + { + await editOperation!.Finish().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log($"Failed to finish external edit operation: {ex}", LoggingTarget.Database, LogLevel.Error); + showSpinner("Import failed!"); + Scheduler.AddDelayed(Hide, 1000); + setGlobalSkinDisabled(false); + taskCompletionSource.SetException(ex); + taskCompletionSource = null; + return; + } + + Schedule(() => + { + var oldSkin = skinManager.CurrentSkin!.Value; + var newSkinInfo = oldSkin.SkinInfo.PerformRead(s => s); + + // Create a new skin instance to ensure the skin is reloaded + // If there's a better way to reload the skin, this should be replaced with it. + setGlobalSkinDisabled(false); + skinManager.CurrentSkin.Value = newSkinInfo.CreateInstance(skinManager); + + oldSkin.Dispose(); + + Hide(); + }); + taskCompletionSource.SetResult(); + taskCompletionSource = null; + } + + private void setGlobalSkinDisabled(bool disabled) + { + skinManager.CurrentSkin.Disabled = disabled; + skinManager.CurrentSkinInfo.Disabled = disabled; + } + + protected override void PopIn() + { + this.FadeIn(transition_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(transition_duration, Easing.OutQuint).Finally(_ => + { + // Set everything to a clean state + editOperation = null; + finishingEdit = false; + flow.Children = Array.Empty(); + }); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.Back: + case GlobalAction.Select: + if (editOperation == null) + return false; + + finish().FireAndForget(); + return true; + } + + return base.OnPressed(e); + } + + private void showSpinner(string text) + { + foreach (var b in flow.ChildrenOfType()) + b.Enabled.Value = false; + + flow.Children = new Drawable[] + { + new OsuSpriteText + { + Text = text, + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new LoadingSpinner + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + State = { Value = Visibility.Visible } + }, + }; + } + + protected override void Dispose(bool isDisposing) + { + if (gameHost.IsNotNull()) + gameHost.ExitRequested -= tryFinishOnExit; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs index 3f8d9f80d4..df8cb33a71 100644 --- a/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs +++ b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs @@ -111,6 +111,16 @@ namespace osu.Game.Overlays.SkinEditor SelectedItems.AddRange(targetComponents.SelectMany(list => list).Except(SelectedItems).ToArray()); } + protected override bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints) + { + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // The final movement position, relative to movementBlueprintOriginalPosition. + var referenceBlueprint = blueprints.First().blueprint; + Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled; + return SelectionHandler.HandleMovement(new MoveSelectionEvent(referenceBlueprint, movePosition - referenceBlueprint.ScreenSpaceSelectionPoint)); + } + /// /// Move the current selection spatially by the specified delta, in screen coordinates (ie. the same coordinates as the blueprints). /// diff --git a/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs b/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs index 85becc1a23..4a3ae99116 100644 --- a/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs +++ b/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs @@ -219,7 +219,7 @@ namespace osu.Game.Overlays.SkinEditor } } - public partial class DependencyBorrowingContainer : Container + private partial class DependencyBorrowingContainer : Container { protected override bool ShouldBeConsideredForInput(Drawable child) => false; @@ -232,8 +232,19 @@ namespace osu.Game.Overlays.SkinEditor this.donor = donor; } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => - new DependencyContainer(donor?.Dependencies ?? base.CreateChildDependencies(parent)); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var baseDependencies = base.CreateChildDependencies(parent); + if (donor == null) + return baseDependencies; + + var dependencies = new DependencyContainer(donor.Dependencies); + // inject `SkinEditor` again *on top* of the borrowed dependencies. + // this is designed to let components know when they are being displayed in the context of the skin editor + // via attempting to resolve `SkinEditor`. + dependencies.CacheAs(baseDependencies.Get()); + return dependencies; + } } } } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 18e01e2490..823456dddd 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -48,6 +48,8 @@ namespace osu.Game.Overlays.SkinEditor public readonly BindableList SelectedComponents = new BindableList(); + public bool ExternalEditInProgress => externalEditOperation != null && !externalEditOperation.IsCompleted; + protected override bool StartHidden => true; private Drawable? targetScreen; @@ -104,6 +106,11 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] + private ExternalEditOverlay? externalEditOverlay { get; set; } + + private Task? externalEditOperation; + public SkinEditor() { } @@ -159,6 +166,7 @@ namespace osu.Game.Overlays.SkinEditor { new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()) { Hotkey = new Hotkey(PlatformAction.Save) }, new EditorMenuItem(CommonStrings.Export, MenuItemType.Standard, () => skins.ExportCurrentSkin()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, () => _ = editExternally()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, new OsuMenuItemSpacer(), new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), new OsuMenuItemSpacer(), @@ -276,6 +284,15 @@ namespace osu.Game.Overlays.SkinEditor selectedTarget.BindValueChanged(targetChanged, true); } + private async Task editExternally() + { + Save(); + + var skin = currentSkin.Value.SkinInfo.PerformRead(s => s.Detach()); + + externalEditOperation = await externalEditOverlay!.Begin(skin).ConfigureAwait(false); + } + public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) @@ -374,9 +391,10 @@ namespace osu.Game.Overlays.SkinEditor return; } - changeHandler = new SkinEditorChangeHandler(skinComponentsContainer); - changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); - changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + if (skinComponentsContainer.ComponentsLoaded) + bindChangeHandler(skinComponentsContainer); + else + skinComponentsContainer.OnComponentsLoaded += onComponentsLoaded; content.Child = new SkinBlueprintContainer(skinComponentsContainer); @@ -410,6 +428,13 @@ namespace osu.Game.Overlays.SkinEditor RequestPlacement = requestPlacement }); + void onComponentsLoaded(Drawable d) + { + SkinnableContainer container = (SkinnableContainer)d; + container.OnComponentsLoaded -= onComponentsLoaded; + Schedule(() => bindChangeHandler(container)); + } + void requestPlacement(Type type) { if (!(Activator.CreateInstance(type) is ISerialisableDrawable component)) @@ -418,14 +443,25 @@ namespace osu.Game.Overlays.SkinEditor SelectedComponents.Clear(); placeComponent(component); } + + void bindChangeHandler(SkinnableContainer skinnableContainer) + { + changeHandler = new SkinEditorChangeHandler(skinnableContainer); + changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); + changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + } } private void skinChanged() { + if (skins.EnsureMutableSkin()) + // Another skin changed event will arrive which will complete the process. + return; + headerText.Clear(); - headerText.AddParagraph(SkinEditorStrings.SkinEditor, cp => cp.Font = OsuFont.Default.With(size: 16)); - headerText.NewParagraph(); + headerText.AddText(SkinEditorStrings.SkinEditor, cp => cp.Font = OsuFont.Default.With(size: 16)); + headerText.NewLine(); headerText.AddText(SkinEditorStrings.CurrentlyEditing, cp => { cp.Font = OsuFont.Default.With(size: 12); @@ -439,17 +475,24 @@ namespace osu.Game.Overlays.SkinEditor }); changeHandler?.Dispose(); + changeHandler = null; - skins.EnsureMutableSkin(); + // Schedule is required to ensure that all layout in `LoadComplete` methods has been completed + // before storing an undo state. + // + // See https://github.com/ppy/osu/blob/8e6a4559e3ae8c9892866cf9cf8d4e8d1b72afd0/osu.Game/Skinning/SkinReloadableDrawable.cs#L76. + Schedule(() => + { + var targetContainer = getTarget(selectedTarget.Value); - var targetContainer = getTarget(selectedTarget.Value); + if (targetContainer != null) + changeHandler = new SkinEditorChangeHandler(targetContainer); - if (targetContainer != null) - changeHandler = new SkinEditorChangeHandler(targetContainer); - hasBegunMutating = true; + hasBegunMutating = true; - // Reload sidebar components. - selectedTarget.TriggerChange(); + // Reload sidebar components. + selectedTarget.TriggerChange(); + }); } /// @@ -741,11 +784,7 @@ namespace osu.Game.Overlays.SkinEditor #region Delegation of IEditorChangeHandler - public event Action? OnStateChange - { - add => throw new NotImplementedException(); - remove => throw new NotImplementedException(); - } + public event Action? OnStateChange; private IEditorChangeHandler? beginChangeHandler; @@ -754,6 +793,9 @@ namespace osu.Game.Overlays.SkinEditor // Change handler may change between begin and end, which can cause unbalanced operations. // Let's track the one that was used when beginning the change so we can call EndChange on it specifically. (beginChangeHandler = changeHandler)?.BeginChange(); + + if (beginChangeHandler != null) + beginChangeHandler.OnStateChange += OnStateChange; } public void EndChange() => beginChangeHandler?.EndChange(); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs index 673ba873c4..b805e50df6 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.SkinEditor return; components = new BindableList { BindTarget = firstTarget.Components }; - components.BindCollectionChanged((_, _) => SaveState()); + components.BindCollectionChanged((_, _) => SaveState(), true); } protected override void WriteCurrentStateToStream(MemoryStream stream) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 571f99bd08..83a5d95bb4 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -28,7 +28,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osu.Game.Users; using osu.Game.Utils; @@ -49,9 +49,15 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private IPerformFromScreenRunner? performer { get; set; } + [Resolved] + private IOverlayManager? overlayManager { get; set; } + [Cached] public readonly EditorClipboard Clipboard = new EditorClipboard(); + [Cached] + private readonly ExternalEditOverlay externalEditOverlay = new ExternalEditOverlay(); + [Resolved] private OsuGame game { get; set; } = null!; @@ -69,6 +75,7 @@ namespace osu.Game.Overlays.SkinEditor private OsuScreen? lastTargetScreen; private InvokeOnDisposal? nestedInputManagerDisable; + private IDisposable? externalEditOverlayRegistration; private readonly LayoutValue drawSizeLayout; @@ -84,6 +91,14 @@ namespace osu.Game.Overlays.SkinEditor private void load(OsuConfigManager config) { config.BindWith(OsuSetting.BeatmapSkins, beatmapSkins); + config.BindWith(OsuSetting.HUDVisibilityMode, configVisibilityMode); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + externalEditOverlayRegistration = overlayManager?.RegisterBlockingOverlay(externalEditOverlay); } public bool OnPressed(KeyBindingPressEvent e) @@ -103,7 +118,7 @@ namespace osu.Game.Overlays.SkinEditor protected override void PopIn() { - globallyDisableBeatmapSkinSetting(); + overrideSkinEditorRelevantSettings(); if (skinEditor != null) { @@ -145,7 +160,7 @@ namespace osu.Game.Overlays.SkinEditor nestedInputManagerDisable?.Dispose(); nestedInputManagerDisable = null; - globallyReenableBeatmapSkinSetting(); + restoreSkinEditorRelevantSettings(); } public void PresentGameplay() => presentGameplay(false); @@ -180,7 +195,7 @@ namespace osu.Game.Overlays.SkinEditor // the validity of the current game-wide beatmap + ruleset combination is enforced by song select. // if we're anywhere else, the state is unknown and may not make sense, so forcibly set something that does. - if (screen is not PlaySongSelect) + if (screen is not SoloSongSelect) ruleset.Value = beatmap.Value.BeatmapInfo.Ruleset; var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod(); @@ -194,7 +209,7 @@ namespace osu.Game.Overlays.SkinEditor if (replayGeneratingMod != null) screen.Push(new EndlessPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods))); - }, new[] { typeof(Player), typeof(PlaySongSelect) }); + }, new[] { typeof(Player), typeof(SoloSongSelect) }); } protected override void Update() @@ -235,7 +250,8 @@ namespace osu.Game.Overlays.SkinEditor Scheduler.AddOnce(updateScreenSizing); game.Toolbar.Hide(); - game.CloseAllOverlays(); + if (externalEditOverlay.State.Value != Visibility.Visible) + game.CloseAllOverlays(); } else { @@ -284,7 +300,8 @@ namespace osu.Game.Overlays.SkinEditor if (skinEditor.State.Value == Visibility.Visible) { - skinEditor.Save(false); + if (externalEditOverlay.State.Value != Visibility.Visible) + skinEditor.Save(false); skinEditor.UpdateTargetScreen(target); disableNestedInputManagers(); } @@ -314,24 +331,49 @@ namespace osu.Game.Overlays.SkinEditor private readonly Bindable beatmapSkins = new Bindable(); private LeasedBindable? leasedBeatmapSkins; - private void globallyDisableBeatmapSkinSetting() - { - if (beatmapSkins.Disabled) - return; + private readonly Bindable configVisibilityMode = new Bindable(); + private LeasedBindable? leasedVisibilityMode; - // The skin editor doesn't work well if beatmap skins are being applied to the player screen. - // To keep things simple, disable the setting game-wide while using the skin editor. - // - // This causes a full reload of the skin, which is pretty ugly. - // TODO: Investigate if we can avoid this when a beatmap skin is not being applied by the current beatmap. - leasedBeatmapSkins = beatmapSkins.BeginLease(true); - leasedBeatmapSkins.Value = false; + private void overrideSkinEditorRelevantSettings() + { + if (!beatmapSkins.Disabled) + { + // The skin editor doesn't work well if beatmap skins are being applied to the player screen. + // To keep things simple, disable the setting game-wide while using the skin editor. + // + // This causes a full reload of the skin, which is pretty ugly. + // TODO: Investigate if we can avoid this when a beatmap skin is not being applied by the current beatmap. + leasedBeatmapSkins = beatmapSkins.BeginLease(true); + leasedBeatmapSkins.Value = false; + } + + leasedVisibilityMode = configVisibilityMode.BeginLease(true); + leasedVisibilityMode.Value = HUDVisibilityMode.Always; } - private void globallyReenableBeatmapSkinSetting() + private void restoreSkinEditorRelevantSettings() { leasedBeatmapSkins?.Return(); leasedBeatmapSkins = null; + + leasedVisibilityMode?.Return(); + leasedVisibilityMode = null; + } + + public new void ToggleVisibility() + { + if (skinEditor?.ExternalEditInProgress == true) + return; + + base.ToggleVisibility(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + externalEditOverlayRegistration?.Dispose(); + externalEditOverlayRegistration = null; } private partial class EndlessPlayer : ReplayPlayer diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs index 5a283c0e8d..f8d5213622 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs @@ -12,8 +12,9 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Screens; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK; +using SongSelect = osu.Game.Screens.Select.SongSelect; namespace osu.Game.Overlays.SkinEditor { @@ -78,7 +79,7 @@ namespace osu.Game.Overlays.SkinEditor if (screen is SongSelect) return; - screen.Push(new PlaySongSelect()); + screen.Push(new SoloSongSelect()); }, new[] { typeof(SongSelect) }) }, new SceneButton diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs index bc878b9214..838b5ff2f0 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs @@ -22,7 +22,10 @@ namespace osu.Game.Overlays.SkinEditor { public partial class SkinSelectionHandler : SelectionHandler { - private OsuMenuItem originMenu = null!; + private OsuMenuItem? originMenu; + + private TernaryStateRadioMenuItem? closestAnchor; + private AnchorMenuItem[]? fixedAnchors; [Resolved] private SkinEditor skinEditor { get; set; } = null!; @@ -44,6 +47,38 @@ namespace osu.Game.Overlays.SkinEditor return scaleHandler; } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (ChangeHandler != null) + ChangeHandler.OnStateChange += updateTernaryStates; + SelectedItems.BindCollectionChanged((_, _) => updateTernaryStates()); + } + + private void updateTernaryStates() + { + var usingClosestAnchor = GetStateFromSelection(SelectedBlueprints, c => !c.Item.UsesFixedAnchor); + + if (closestAnchor != null) + closestAnchor.State.Value = usingClosestAnchor; + + if (fixedAnchors != null) + { + foreach (var fixedAnchor in fixedAnchors) + fixedAnchor.State.Value = GetStateFromSelection(SelectedBlueprints, c => c.Item.UsesFixedAnchor && ((Drawable)c.Item).Anchor == fixedAnchor.Anchor); + } + + if (originMenu != null) + { + foreach (var origin in originMenu.Items.OfType()) + { + origin.State.Value = GetStateFromSelection(SelectedBlueprints, c => ((Drawable)c.Item).Origin == origin.Anchor); + origin.Action.Disabled = usingClosestAnchor == TernaryState.True; + } + } + } + public override bool HandleFlip(Direction direction, bool flipOverOrigin) { var selectionQuad = getSelectionQuad(); @@ -102,27 +137,17 @@ namespace osu.Game.Overlays.SkinEditor protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { - var closestItem = new TernaryStateRadioMenuItem(SkinEditorStrings.Closest, MenuItemType.Standard, _ => applyClosestAnchors()) - { - State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) } - }; + closestAnchor = new TernaryStateRadioMenuItem(SkinEditorStrings.Closest, MenuItemType.Standard, _ => applyClosestAnchors()); + fixedAnchors = createAnchorItems(applyFixedAnchors).ToArray(); yield return new OsuMenuItem(SkinEditorStrings.Anchor) { - Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors) - .Prepend(closestItem) - .ToArray() + Items = fixedAnchors.Prepend(closestAnchor).ToArray() }; yield return originMenu = new OsuMenuItem(SkinEditorStrings.Origin); - closestItem.State.BindValueChanged(s => - { - // For UX simplicity, origin should only be user-editable when "closest" anchor mode is disabled. - originMenu.Items = s.NewValue == TernaryState.True - ? Array.Empty() - : createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray(); - }, true); + originMenu.Items = createAnchorItems(applyOrigins).ToArray(); yield return new OsuMenuItemSpacer(); @@ -163,27 +188,37 @@ namespace osu.Game.Overlays.SkinEditor foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; - IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) + updateTernaryStates(); + } + + private IEnumerable createAnchorItems(Action applyFunction) + { + var displayableAnchors = new[] { - var displayableAnchors = new[] - { - Anchor.TopLeft, - Anchor.TopCentre, - Anchor.TopRight, - Anchor.CentreLeft, - Anchor.Centre, - Anchor.CentreRight, - Anchor.BottomLeft, - Anchor.BottomCentre, - Anchor.BottomRight, - }; - return displayableAnchors.Select(a => - { - return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a)) - { - State = { Value = GetStateFromSelection(selection, c => checkFunction(c.Item, a)) } - }; - }); + Anchor.TopLeft, + Anchor.TopCentre, + Anchor.TopRight, + Anchor.CentreLeft, + Anchor.Centre, + Anchor.CentreRight, + Anchor.BottomLeft, + Anchor.BottomCentre, + Anchor.BottomRight, + }; + return displayableAnchors.Select(a => + { + return new AnchorMenuItem(a, _ => applyFunction(a)); + }); + } + + private partial class AnchorMenuItem : TernaryStateRadioMenuItem + { + public readonly Anchor Anchor; + + public AnchorMenuItem(Anchor anchor, Action applyFunction) + : base(anchor.ToString(), MenuItemType.Standard, _ => applyFunction(anchor)) + { + Anchor = anchor; } } diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs index 9fd28a1cad..2438abe5d9 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs @@ -59,7 +59,7 @@ namespace osu.Game.Overlays.SkinEditor objectsInRotation = selectedItems.Cast().ToArray(); originalRotations = objectsInRotation.ToDictionary(d => d, d => d.Rotation); originalPositions = objectsInRotation.ToDictionary(d => d, d => d.ToScreenSpace(d.OriginPosition)); - DefaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre; + DefaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => ToLocalSpace(d.ScreenSpaceDrawQuad).GetVertices().ToArray())).Centre; base.Begin(); } @@ -82,7 +82,7 @@ namespace osu.Game.Overlays.SkinEditor foreach (var drawableItem in objectsInRotation) { - var rotatedPosition = GeometryUtils.RotatePointAroundOrigin(originalPositions[drawableItem], actualOrigin, rotation); + var rotatedPosition = GeometryUtils.RotatePointAroundOrigin(originalPositions[drawableItem], ToScreenSpace(actualOrigin), rotation); UpdatePosition(drawableItem, rotatedPosition); drawableItem.Rotation = originalRotations[drawableItem] + rotation; diff --git a/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs b/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs index a5ed0d65bd..000afd2c1d 100644 --- a/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs @@ -70,8 +70,18 @@ namespace osu.Game.Overlays.Toolbar float rotation = fraction * 360 - 90; + // The case where a hand is completing a rotation. + // Animate and then move back one full rotation so we don't need to track outside of 0..360 if (Math.Abs(hand.Rotation - rotation) > 180) - hand.RotateTo(rotation); + { + float animRotation = rotation; + while (animRotation < hand.Rotation) + animRotation += 180; + + hand.RotateTo(animRotation, duration, Easing.OutElastic) + .Then() + .RotateTo(rotation); + } else hand.RotateTo(rotation, duration, Easing.OutElastic); } diff --git a/osu.Game/Overlays/Toolbar/ClockDisplay.cs b/osu.Game/Overlays/Toolbar/ClockDisplay.cs index c72c92b61b..0b223c6038 100644 --- a/osu.Game/Overlays/Toolbar/ClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/ClockDisplay.cs @@ -10,6 +10,14 @@ namespace osu.Game.Overlays.Toolbar { private int? lastSecond; + protected override void LoadComplete() + { + base.LoadComplete(); + + UpdateDisplay(DateTimeOffset.Now); + FinishTransforms(true); + } + protected override void Update() { base.Update(); diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs index ada2f6ff86..bd1c944847 100644 --- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs @@ -7,8 +7,10 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osuTK; namespace osu.Game.Overlays.Toolbar { @@ -17,6 +19,8 @@ namespace osu.Game.Overlays.Toolbar private OsuSpriteText realTime; private OsuSpriteText gameTime; + private FillFlowContainer runningText; + private bool showRuntime = true; public bool ShowRuntime @@ -52,17 +56,36 @@ namespace osu.Game.Overlays.Toolbar [BackgroundDependencyLoader] private void load(OsuColour colours) { - AutoSizeAxes = Axes.Y; + AutoSizeAxes = Axes.Both; InternalChildren = new Drawable[] { - realTime = new OsuSpriteText(), - gameTime = new OsuSpriteText + realTime = new OsuSpriteText + { + Font = OsuFont.Default.With(fixedWidth: true), + Spacing = new Vector2(-1.5f, 0), + }, + runningText = new FillFlowContainer { Y = 14, Colour = colours.PinkLight, - Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), - } + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "running", + Font = OsuFont.Default.With(size: 10, weight: FontWeight.SemiBold), + }, + gameTime = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 10, fixedWidth: true, weight: FontWeight.SemiBold), + Spacing = new Vector2(-0.5f, 0), + } + } + }, }; updateMetrics(); @@ -71,14 +94,12 @@ namespace osu.Game.Overlays.Toolbar protected override void UpdateDisplay(DateTimeOffset now) { realTime.Text = now.ToLocalisableString(use24HourDisplay ? @"HH:mm:ss" : @"h:mm:ss tt"); - gameTime.Text = $"running {new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; + gameTime.Text = $"{new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; } private void updateMetrics() { - Width = showRuntime || !use24HourDisplay ? 66 : 45; // Allows for space for game time up to 99 days (in the padding area since this is quite rare). - - gameTime.FadeTo(showRuntime ? 1 : 0); + runningText.FadeTo(showRuntime ? 1 : 0); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 221282ef13..5b75b8419c 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -77,6 +78,8 @@ namespace osu.Game.Overlays.Toolbar protected readonly Container BackgroundContent; + private IDisposable? realmSubscription; + [Resolved] private RealmAccess realm { get; set; } = null!; @@ -184,7 +187,8 @@ namespace osu.Game.Overlays.Toolbar { if (Hotkey != null) { - realm.SubscribeToPropertyChanged(r => r.All().FirstOrDefault(rkb => rkb.RulesetName == null && rkb.ActionInt == (int)Hotkey.Value), kb => kb.KeyCombinationString, updateKeyBindingTooltip); + realmSubscription = realm.SubscribeToPropertyChanged(r => r.All().FirstOrDefault(rkb => rkb.RulesetName == null && rkb.ActionInt == (int)Hotkey.Value), + kb => kb.KeyCombinationString, updateKeyBindingTooltip); } } @@ -234,6 +238,13 @@ namespace osu.Game.Overlays.Toolbar ? $" ({keyBindingString})" : string.Empty; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + realmSubscription?.Dispose(); + } } public partial class OpaqueBackground : Container diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index a979575a0b..0e2fa6688d 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.IO.Stores; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -32,6 +33,8 @@ namespace osu.Game.Overlays.Toolbar private readonly Dictionary rulesetSelectionChannel = new Dictionary(); private Sample defaultSelectSample; + private ISampleStore samples; + public ToolbarRulesetSelector() { RelativeSizeAxes = Axes.Y; @@ -39,7 +42,7 @@ namespace osu.Game.Overlays.Toolbar } [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load(AudioManager audio, OsuGameBase game) { AddRangeInternal(new[] { @@ -66,8 +69,15 @@ namespace osu.Game.Overlays.Toolbar }, }); + var store = new ResourceStore(game.Resources); + samples = audio.GetSampleStore(new NamespacedResourceStore(store, "Samples"), audio.SampleMixer); + foreach (var r in Rulesets.AvailableRulesets) - rulesetSelectionSample[r] = audio.Samples.Get($@"UI/ruleset-select-{r.ShortName}"); + { + store.AddStore(r.CreateInstance().CreateResourceStore()); + + rulesetSelectionSample[r] = samples.Get($@"UI/ruleset-select-{r.ShortName}"); + } defaultSelectSample = audio.Samples.Get(@"UI/default-select"); @@ -159,5 +169,12 @@ namespace osu.Game.Overlays.Toolbar return false; } + + protected override void Dispose(bool isDisposing) + { + samples?.Dispose(); + + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs index d5891da936..85b7358d2d 100644 --- a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs +++ b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs @@ -83,7 +83,12 @@ namespace osu.Game.Overlays.Toolbar } if (update.After.PP != null) - pp.Display((int)(update.Before.PP ?? update.After.PP.Value), (int)Math.Abs(((int?)update.After.PP - (int?)update.Before.PP) ?? 0M), (int)update.After.PP.Value); + { + int before = (int)Math.Round(update.Before.PP ?? update.After.PP.Value); + int after = (int)Math.Round(update.After.PP.Value); + int delta = Math.Abs(after - before); + pp.Display(before, delta, after); + } this.Delay(5000).FadeOut(500, Easing.OutQuint); }); diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index 076905819e..edaa1bdc89 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; 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.Events; @@ -55,13 +56,17 @@ namespace osu.Game.Overlays public UserProfileOverlay() : base(OverlayColourScheme.Pink) { - base.Content.AddRange(new Drawable[] + base.Content.Add(new PopoverContainer { - onlineViewContainer = new OnlineViewContainer($"Sign in to view the {Header.Title.Title}") + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both - }, - loadingLayer = new LoadingLayer(true) + onlineViewContainer = new OnlineViewContainer($"Sign in to view the {Header.Title.Title}") + { + RelativeSizeAxes = Axes.Both + }, + loadingLayer = new LoadingLayer(true) + } }); } diff --git a/osu.Game/Overlays/Volume/GlobalScrollAdjustsVolume.cs b/osu.Game/Overlays/Volume/GlobalScrollAdjustsVolume.cs new file mode 100644 index 0000000000..f1ad88833b --- /dev/null +++ b/osu.Game/Overlays/Volume/GlobalScrollAdjustsVolume.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; + +namespace osu.Game.Overlays.Volume +{ + /// + /// Add to a container or screen to make scrolling anywhere in the container cause the global game volume to be adjusted. + /// + /// + /// This is generally expected behaviour in many locations in osu!stable. + /// + public partial class GlobalScrollAdjustsVolume : Container + { + [Resolved] + private VolumeOverlay? volumeOverlay { get; set; } + + public GlobalScrollAdjustsVolume() + { + RelativeSizeAxes = Axes.Both; + } + + protected override bool OnScroll(ScrollEvent e) + { + if (e.ScrollDelta.Y == 0) + return false; + + // forward any unhandled mouse scroll events to the volume control. + return volumeOverlay?.Adjust(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise) ?? false; + } + } +} diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs deleted file mode 100644 index 2e8d86d4c7..0000000000 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Game.Input.Bindings; - -namespace osu.Game.Overlays.Volume -{ - public partial class VolumeControlReceptor : Container, IScrollBindingHandler, IHandleGlobalKeyboardInput - { - public Func ActionRequested; - public Func ScrollActionRequested; - - public bool OnPressed(KeyBindingPressEvent e) - { - switch (e.Action) - { - case GlobalAction.DecreaseVolume: - case GlobalAction.IncreaseVolume: - return ActionRequested?.Invoke(e.Action) == true; - - case GlobalAction.ToggleMute: - case GlobalAction.NextVolumeMeter: - case GlobalAction.PreviousVolumeMeter: - if (!e.Repeat) - return ActionRequested?.Invoke(e.Action) == true; - - return false; - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - - protected override bool OnScroll(ScrollEvent e) - { - if (e.ScrollDelta.Y == 0) - return false; - - // forward any unhandled mouse scroll events to the volume control. - ScrollActionRequested?.Invoke(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise); - return true; - } - - public bool OnScroll(KeyBindingScrollEvent e) => - ScrollActionRequested?.Invoke(e.Action, e.ScrollAmount, e.IsPrecise) ?? false; - } -} diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index d64d6b934a..a5129eaefd 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -5,13 +5,20 @@ using System; using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; +using osuTK; namespace osu.Game.Overlays.Wiki { @@ -19,11 +26,15 @@ namespace osu.Game.Overlays.Wiki { public static LocalisableString IndexPageString => LayoutStrings.HeaderHelpIndex; + private const string github_wiki_base = @"https://github.com/ppy/osu-wiki/blob/master/wiki"; + public readonly Bindable WikiPageData = new Bindable(); public Action ShowIndexPage; public Action ShowParentPage; + private readonly Bindable githubPath = new Bindable(); + public WikiHeader() { TabControl.AddItem(IndexPageString); @@ -35,6 +46,9 @@ namespace osu.Game.Overlays.Wiki private void onWikiPageChange(ValueChangedEvent e) { + // Clear the path beforehand in case we got an error page. + githubPath.Value = null; + if (e.NewValue == null) return; @@ -42,6 +56,7 @@ namespace osu.Game.Overlays.Wiki Current.Value = null; TabControl.AddItem(IndexPageString); + githubPath.Value = $"{github_wiki_base}/{e.NewValue.Path}/{e.NewValue.Locale}.md"; if (e.NewValue.Path == WikiOverlay.INDEX_PATH) { @@ -56,6 +71,27 @@ namespace osu.Game.Overlays.Wiki Current.Value = e.NewValue.Title; } + protected override Drawable CreateTabControlContent() + { + return new FillFlowContainer + { + Height = 40, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new ShowOnGitHubButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(32), + TargetPath = { BindTarget = githubPath }, + }, + }, + }; + } + private void onCurrentChange(ValueChangedEvent e) { if (e.NewValue == TabControl.Items.LastOrDefault()) @@ -83,5 +119,39 @@ namespace osu.Game.Overlays.Wiki Icon = OsuIcon.Wiki; } } + + private partial class ShowOnGitHubButton : RoundedButton + { + public override LocalisableString TooltipText => WikiStrings.ShowEditLink; + + public readonly Bindable TargetPath = new Bindable(); + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] ILinkHandler linkHandler) + { + Width = 42; + + Add(new SpriteIcon + { + Size = new Vector2(12), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Brands.Github, + }); + + Action = () => linkHandler?.HandleLink(TargetPath.Value); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TargetPath.BindValueChanged(e => + { + this.FadeTo(e.NewValue != null ? 1 : 0); + Enabled.Value = e.NewValue != null; + }, true); + } + } } } diff --git a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs index 555dab852e..81bdae5525 100644 --- a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs +++ b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Wiki Padding = new MarginPadding(padding), Child = new WikiPanelMarkdownContainer(isFullWidth) { - CurrentPath = $@"{api.WebsiteRootUrl}/wiki/", + CurrentPath = $@"{api.Endpoints.WebsiteUrl}/wiki/", Text = text, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 14a25a909d..e9099f1deb 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -100,7 +100,7 @@ namespace osu.Game.Overlays if (articlePage != null) { articlePage.SidebarContainer.Height = DrawHeight; - articlePage.SidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + articlePage.SidebarContainer.Y = (float)Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); } } @@ -167,7 +167,7 @@ namespace osu.Game.Overlays } else { - LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/{path.Value}/", response.Markdown)); + LoadDisplay(articlePage = new WikiArticlePage($@"{api.Endpoints.WebsiteUrl}/wiki/{path.Value}/", response.Markdown)); } } @@ -176,7 +176,7 @@ namespace osu.Game.Overlays wikiData.Value = null; path.Value = "error"; - LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", + LoadDisplay(articlePage = new WikiArticlePage($@"{api.Endpoints.WebsiteUrl}/wiki/", $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page]({INDEX_PATH}).")); } diff --git a/osu.Game/Overlays/WizardOverlay.cs b/osu.Game/Overlays/WizardOverlay.cs new file mode 100644 index 0000000000..3cc403dbff --- /dev/null +++ b/osu.Game/Overlays/WizardOverlay.cs @@ -0,0 +1,289 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +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.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays.Mods; +using osu.Game.Screens.Footer; + +namespace osu.Game.Overlays +{ + public partial class WizardOverlay : ShearedOverlayContainer + { + private ScreenStack? stack; + + public ShearedButton? NextButton => DisplayedFooterContent?.NextButton; + + protected int? CurrentStepIndex { get; private set; } + + /// + /// The currently displayed screen, if any. + /// + public WizardScreen? CurrentScreen => (WizardScreen?)stack?.CurrentScreen; + + private readonly List steps = new List(); + + private Container screenContent = null!; + + private Container content = null!; + + private LoadingSpinner loading = null!; + private ScheduledDelegate? loadingShowDelegate; + + public bool Completed { get; private set; } + + protected WizardOverlay(OverlayColourScheme scheme) + : base(scheme) + { + } + + [BackgroundDependencyLoader] + private void load() + { + MainAreaContent.AddRange(new Drawable[] + { + content = new PopoverContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 20 }, + Child = new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(minSize: 640, maxSize: 800), + new Dimension(), + }, + Content = new[] + { + new[] + { + Empty(), + new InputBlockingContainer + { + Masking = true, + CornerRadius = 14, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background6, + }, + loading = new LoadingSpinner(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 20 }, + Child = screenContent = new Container { RelativeSizeAxes = Axes.Both, }, + }, + }, + }, + Empty(), + }, + } + } + }, + }); + } + + [Resolved] + private ScreenFooter footer { get; set; } = null!; + + public new WizardFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as WizardFooterContent; + + public override VisibilityContainer CreateFooterContent() + { + var footerContent = new WizardFooterContent + { + ShowNextStep = ShowNextStep, + }; + + footerContent.OnLoadComplete += _ => updateButtons(); + return footerContent; + } + + public override bool OnBackButton() + { + if (CurrentStepIndex == 0) + return false; + + Debug.Assert(stack != null); + + stack.CurrentScreen.Exit(); + CurrentStepIndex--; + + updateButtons(); + return true; + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (!e.Repeat) + { + switch (e.Action) + { + case GlobalAction.Select: + DisplayedFooterContent?.NextButton.TriggerClick(); + return true; + + case GlobalAction.Back: + footer.BackButton.TriggerClick(); + return false; + } + } + + return base.OnPressed(e); + } + + protected override void PopIn() + { + base.PopIn(); + + content.ScaleTo(0.99f) + .ScaleTo(1, 400, Easing.OutQuint); + + if (CurrentStepIndex == null) + showFirstStep(); + } + + protected override void PopOut() + { + base.PopOut(); + + content.ScaleTo(0.99f, 400, Easing.OutQuint); + + if (CurrentStepIndex == null) + { + stack?.FadeOut(100) + .Expire(); + } + } + + protected void AddStep() + where T : WizardScreen + { + steps.Add(typeof(T)); + } + + private void showFirstStep() + { + Debug.Assert(CurrentStepIndex == null); + + screenContent.Child = stack = new ScreenStack + { + RelativeSizeAxes = Axes.Both, + }; + + CurrentStepIndex = -1; + ShowNextStep(); + } + + protected virtual void ShowNextStep() + { + Debug.Assert(CurrentStepIndex != null); + Debug.Assert(stack != null); + + CurrentStepIndex++; + + if (CurrentStepIndex < steps.Count) + { + var nextScreen = (Screen)Activator.CreateInstance(steps[CurrentStepIndex.Value])!; + + loadingShowDelegate = Scheduler.AddDelayed(() => loading.Show(), 200); + nextScreen.OnLoadComplete += _ => + { + loadingShowDelegate?.Cancel(); + loading.Hide(); + }; + + stack.Push(nextScreen); + } + else + { + CurrentStepIndex = null; + Completed = true; + Hide(); + } + + updateButtons(); + } + + private void updateButtons() => DisplayedFooterContent?.UpdateButtons(CurrentStepIndex, CurrentScreen, steps); + + public partial class WizardFooterContent : VisibilityContainer + { + public ShearedButton NextButton { get; private set; } = null!; + + public Action? ShowNextStep; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Both; + + Padding = new MarginPadding { Right = OsuGame.SCREEN_EDGE_MARGIN }; + + InternalChild = NextButton = new ShearedButton(0) + { + RelativeSizeAxes = Axes.X, + Width = 1, + Text = FirstRunSetupOverlayStrings.GetStarted, + DarkerColour = colourProvider.Colour3, + LighterColour = colourProvider.Colour2, + Action = () => ShowNextStep?.Invoke(), + }; + } + + public void UpdateButtons(int? currentStep, WizardScreen? currentScreen, IReadOnlyList steps) + { + NextButton.Enabled.Value = currentStep != null; + + if (currentStep == null) + return; + + bool isLastStep = currentStep == steps.Count - 1; + + if (currentScreen?.NextStepText != null) + NextButton.Text = currentScreen.NextStepText.Value; + else + { + NextButton.Text = isLastStep + ? CommonStrings.Finish + : LocalisableString.Interpolate($@"{CommonStrings.Next} ({steps[currentStep.Value + 1].GetLocalisableDescription()})"); + } + } + + protected override void PopIn() + { + this.FadeIn(); + } + + protected override void PopOut() + { + this.Delay(400).FadeOut(); + } + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs b/osu.Game/Overlays/WizardScreen.cs similarity index 94% rename from osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs rename to osu.Game/Overlays/WizardScreen.cs index 76921718f2..5112efaa61 100644 --- a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs +++ b/osu.Game/Overlays/WizardScreen.cs @@ -7,15 +7,16 @@ using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -namespace osu.Game.Overlays.FirstRunSetup +namespace osu.Game.Overlays { - public abstract partial class FirstRunSetupScreen : Screen + public abstract partial class WizardScreen : Screen { private const float offset = 100; @@ -102,5 +103,7 @@ namespace osu.Game.Overlays.FirstRunSetup base.OnSuspending(e); } + + public virtual LocalisableString? NextStepText => null; } } diff --git a/osu.Game/Properties/AssemblyInfo.cs b/osu.Game/Properties/AssemblyInfo.cs index be430a0fe4..75e3ff8fd0 100644 --- a/osu.Game/Properties/AssemblyInfo.cs +++ b/osu.Game/Properties/AssemblyInfo.cs @@ -11,6 +11,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("osu.Game.Tests.Dynamic")] [assembly: InternalsVisibleTo("osu.Game.Tests.iOS")] [assembly: InternalsVisibleTo("osu.Game.Tests.Android")] +[assembly: InternalsVisibleTo("osu.Game.Tournament.Tests")] // intended for Moq usage [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index b48fc44963..bfc8ad5df8 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -64,5 +64,12 @@ namespace osu.Game.Replays.Legacy { return $"{Time}\t({MouseX},{MouseY})\t{MouseLeft}\t{MouseRight}\t{MouseLeft1}\t{MouseRight1}\t{MouseLeft2}\t{MouseRight2}\t{ButtonState}"; } + + public override bool IsEquivalentTo(ReplayFrame other) + => other is LegacyReplayFrame legacyFrame + && Time == legacyFrame.Time + && MouseX == legacyFrame.MouseX + && MouseY == legacyFrame.MouseY + && ButtonState == legacyFrame.ButtonState; } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 7b6bc37a61..5e431dc357 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -17,19 +17,22 @@ namespace osu.Game.Rulesets.Difficulty { protected const int ATTRIB_ID_AIM = 1; protected const int ATTRIB_ID_SPEED = 3; - protected const int ATTRIB_ID_OVERALL_DIFFICULTY = 5; - protected const int ATTRIB_ID_APPROACH_RATE = 7; protected const int ATTRIB_ID_MAX_COMBO = 9; protected const int ATTRIB_ID_DIFFICULTY = 11; - protected const int ATTRIB_ID_GREAT_HIT_WINDOW = 13; - protected const int ATTRIB_ID_SCORE_MULTIPLIER = 15; protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; protected const int ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT = 23; protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25; - protected const int ATTRIB_ID_OK_HIT_WINDOW = 27; protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29; + protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31; + protected const int ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR = 33; + protected const int ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR = 35; + protected const int ATTRIB_ID_NESTED_SCORE_PER_OBJECT = 37; + protected const int ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER = 39; + protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; + protected const int ATTRIB_ID_RHYTHM_DIFFICULTY = 43; + protected const int ATTRIB_ID_CONSISTENCY_FACTOR = 45; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 14acc9b908..7acfbe651f 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using JetBrains.Annotations; -using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Lists; using osu.Game.Beatmaps; @@ -29,11 +28,15 @@ namespace osu.Game.Rulesets.Difficulty /// protected IBeatmap Beatmap { get; private set; } + /// + /// The working beatmap for which difficulty will be calculated. + /// + protected readonly IWorkingBeatmap WorkingBeatmap; + private Mod[] playableMods; private double clockRate; private readonly IRulesetInfo ruleset; - private readonly IWorkingBeatmap beatmap; /// /// A yymmdd version which is used to discern when reprocessing is required. @@ -43,7 +46,7 @@ namespace osu.Game.Rulesets.Difficulty protected DifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { this.ruleset = ruleset; - this.beatmap = beatmap; + WorkingBeatmap = beatmap; } /// @@ -62,7 +65,13 @@ namespace osu.Game.Rulesets.Difficulty /// A structure describing the difficulty of the beatmap. public DifficultyAttributes Calculate([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) { + using var timedCancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + if (!cancellationToken.CanBeCanceled) + cancellationToken = timedCancellationSource.Token; + cancellationToken.ThrowIfCancellationRequested(); + // ReSharper disable once PossiblyMistakenUseOfCancellationToken preProcess(mods, cancellationToken); var skills = CreateSkills(Beatmap, playableMods, clockRate); @@ -98,7 +107,13 @@ namespace osu.Game.Rulesets.Difficulty /// The set of . public List CalculateTimed([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) { + using var timedCancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + if (!cancellationToken.CanBeCanceled) + cancellationToken = timedCancellationSource.Token; + cancellationToken.ThrowIfCancellationRequested(); + // ReSharper disable once PossiblyMistakenUseOfCancellationToken preProcess(mods, cancellationToken); var attribs = new List(); @@ -166,19 +181,12 @@ namespace osu.Game.Rulesets.Difficulty /// /// The original list of s. /// The cancellation token. - private void preProcess([NotNull] IEnumerable mods, CancellationToken cancellationToken = default) + private void preProcess([NotNull] IEnumerable mods, CancellationToken cancellationToken) { playableMods = mods.Select(m => m.DeepClone()).ToArray(); + Beatmap = WorkingBeatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); - // Only pass through the cancellation token if it's non-default. - // This allows for the default timeout to be applied for playable beatmap construction. - Beatmap = cancellationToken == default - ? beatmap.GetPlayableBeatmap(ruleset, playableMods) - : beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); - - var track = new TrackVirtual(10000); - playableMods.OfType().ForEach(m => m.ApplyToTrack(track)); - clockRate = track.Rate; + clockRate = ModUtils.CalculateRateWithMods(playableMods); } /// @@ -339,6 +347,7 @@ namespace osu.Game.Rulesets.Difficulty public double TotalBreakTime => baseBeatmap.TotalBreakTime; public IEnumerable GetStatistics() => baseBeatmap.GetStatistics(); public double GetMostCommonBeatLength() => baseBeatmap.GetMostCommonBeatLength(); + public int BeatmapVersion => baseBeatmap.BeatmapVersion; public IBeatmap Clone() => new ProgressiveCalculationBeatmap(baseBeatmap.Clone()); public double AudioLeadIn diff --git a/osu.Game/Rulesets/Difficulty/RulesetBeatmapDifficulty.cs b/osu.Game/Rulesets/Difficulty/RulesetBeatmapDifficulty.cs new file mode 100644 index 0000000000..db76974c44 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/RulesetBeatmapDifficulty.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Difficulty +{ + /// + /// A is like a single property from , + /// but adjusted for display in the context of a specific ruleset. + /// The reason why this record exists is that rulesets use in different ways. + /// Some rulesets completely ignore some fields from , + /// some reuse fields in weird ways (like mania reusing to mean key count), + /// some want to provide specific extended information for a field + /// or adjust the "effective display" in different ways. + /// + public class RulesetBeatmapAttribute + { + /// + /// The long label for this beatmap attribute. + /// + public LocalisableString Label { get; } + + /// + /// A two-letter acronym for this beatmap attribute. + /// + public string Acronym { get; } + + /// + /// The value of this attribute before application of mods. + /// + public float OriginalValue { get; } + + /// + /// The "effective" value of this attribute after application of mods. + /// + public float AdjustedValue { get; } + + /// + /// The highest allowable value of this attribute. + /// + public float MaxValue { get; } + + /// + /// An optional extended description of this attribute. + /// + public LocalisableString? Description { get; init; } + + /// + /// Contains any and all additional metrics about how this attribute affects gameplay to show to the users. + /// + public AdditionalMetric[] AdditionalMetrics { get; init; } = []; + + public RulesetBeatmapAttribute(LocalisableString label, string acronym, float originalValue, float adjustedValue, float maxValue) + { + Label = label; + Acronym = acronym; + OriginalValue = originalValue; + AdjustedValue = adjustedValue; + MaxValue = maxValue; + } + + public record AdditionalMetric(LocalisableString Name, LocalisableString Value, Colour4? Colour = null); + } +} diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 3ba67793dc..b6272bf56b 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -116,6 +116,8 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(currentSectionPeak); + public IEnumerable GetObjectStrains() => ObjectStrains; + /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs index b9efcd683d..c813627d51 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs @@ -2,10 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; namespace osu.Game.Rulesets.Difficulty.Utils { - public static class DifficultyCalculationUtils + public static partial class DifficultyCalculationUtils { /// /// Converts BPM value into milliseconds @@ -46,5 +47,148 @@ namespace osu.Game.Rulesets.Difficulty.Utils /// Exponent /// The output of logistic function public static double Logistic(double exponent, double maxValue = 1) => maxValue / (1 + Math.Exp(exponent)); + + /// + /// Returns the p-norm of an n-dimensional vector (https://en.wikipedia.org/wiki/Norm_(mathematics)) + /// + /// The value of p to calculate the norm for. + /// The coefficients of the vector. + /// The p-norm of the vector. + public static double Norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + + /// + /// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function) + /// + /// Value to calculate the function for + /// The mean (center) of the bell curve + /// The width (spread) of the curve + /// Multiplier to adjust the curve's height + /// The output of the bell curve function of + public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Math.Pow(x - mean, 2) / Math.Pow(width, 2))); + + /// + /// Calculates a Smoothstep Bellcurve that returns returns 1 for x = mean, and smoothly reducing it's value to 0 over width + /// + /// Value to calculate the function for + /// Value of x, for which return value will be the highest (=1) + /// Range [mean - width, mean + width] where function will change values + /// The output of the smoothstep bell curve function of + public static double SmoothstepBellCurve(double x, double mean = 0.5, double width = 0.5) + { + x -= mean; + x = x > 0 ? (width - x) : (width + x); + return Smoothstep(x, 0, width); + } + + /// + /// Smoothstep function (https://en.wikipedia.org/wiki/Smoothstep) + /// + /// Value to calculate the function for + /// Value at which function returns 0 + /// Value at which function returns 1 + public static double Smoothstep(double x, double start, double end) + { + x = Math.Clamp((x - start) / (end - start), 0.0, 1.0); + + return x * x * (3.0 - 2.0 * x); + } + + /// + /// Smootherstep function (https://en.wikipedia.org/wiki/Smoothstep#Variations) + /// + /// Value to calculate the function for + /// Value at which function returns 0 + /// Value at which function returns 1 + public static double Smootherstep(double x, double start, double end) + { + x = Math.Clamp((x - start) / (end - start), 0.0, 1.0); + + return x * x * x * (x * (6.0 * x - 15.0) + 10.0); + } + + /// + /// Reverse linear interpolation function (https://en.wikipedia.org/wiki/Linear_interpolation) + /// + /// Value to calculate the function for + /// Value at which function returns 0 + /// Value at which function returns 1 + public static double ReverseLerp(double x, double start, double end) + { + return Math.Clamp((x - start) / (end - start), 0.0, 1.0); + } + + /// + /// Error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double Erf(double x) + { + if (x == 0) + return 0; + + if (double.IsPositiveInfinity(x)) + return 1; + + if (double.IsNegativeInfinity(x)) + return -1; + + if (double.IsNaN(x)) + return double.NaN; + + // Constants for approximation (Abramowitz and Stegun formula 7.1.26) + double t = 1.0 / (1.0 + 0.3275911 * Math.Abs(x)); + double tau = t * (0.254829592 + + t * (-0.284496736 + + t * (1.421413741 + + t * (-1.453152027 + + t * 1.061405429)))); + + double erf = 1.0 - tau * Math.Exp(-x * x); + + return x >= 0 ? erf : -erf; + } + + /// + /// Complementary error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double Erfc(double x) => 1 - Erf(x); + + /// + /// Inverse error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double ErfInv(double x) + { + if (x <= -1) + return double.NegativeInfinity; + + if (x >= 1) + return double.PositiveInfinity; + + if (x == 0) + return 0; + + const double a = 0.147; + double sgn = Math.Sign(x); + x = Math.Abs(x); + + double ln = Math.Log(1 - x * x); + double t1 = 2 / (Math.PI * a) + ln / 2; + double t2 = ln / a; + double baseApprox = Math.Sqrt(t1 * t1 - t2) - t1; + + // Correction reduces max error from -0.005 to -0.00045. + double c = x >= 0.85 ? Math.Pow((x - 0.85) / 0.293, 8) : 0; + double erfInv = sgn * (Math.Sqrt(baseApprox) + c); + + return erfInv; + } + + /// + /// Inverse complementary error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Value to calculate the function for + public static double ErfcInv(double x) => ErfInv(1 - x); } } diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index 642b878a7b..f780472f20 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -19,6 +19,7 @@ namespace osu.Game.Rulesets.Edit new CheckBackgroundPresence(), new CheckBackgroundQuality(), new CheckVideoResolution(), + new CheckVideoUsage(), // Audio new CheckAudioPresence(), @@ -30,25 +31,31 @@ namespace osu.Game.Rulesets.Edit new CheckDelayedHitsounds(), new CheckSongFormat(), new CheckHitsoundsFormat(), + new CheckInconsistentAudio(), // Files new CheckZeroByteFiles(), // Compose new CheckUnsnappedObjects(), - new CheckConcurrentObjects(), new CheckZeroLengthObjects(), new CheckDrainLength(), new CheckUnusedAudioAtEnd(), // Timing new CheckPreviewTime(), + new CheckInconsistentTimingControlPoints(), // Events new CheckBreaks(), // Metadata new CheckTitleMarkers(), + new CheckInconsistentMetadata(), + new CheckMissingGenreLanguage(), + + // Settings + new CheckInconsistentSettings(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs index 53bdf3140c..731a33b3ca 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.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 osu.Game.Beatmaps; namespace osu.Game.Rulesets.Edit @@ -12,25 +14,76 @@ namespace osu.Game.Rulesets.Edit public class BeatmapVerifierContext { /// - /// The playable beatmap instance of the current beatmap. + /// Collects the constituent parts of a beatmap being verified. /// - public readonly IBeatmap Beatmap; - - /// - /// The working beatmap instance of the current beatmap. - /// - public readonly IWorkingBeatmap WorkingBeatmap; + /// + /// Use this to access beatmap resources like its track, storyboard, waveform, or similar. + /// + /// + /// The in its actual playable state after beatmap conversion. + /// Use this to inspect the actual beatmap contents, like its hitobjects, timing points, breaks, etc. + /// + public record VerifiedBeatmap(IWorkingBeatmap Working, IBeatmap Playable); /// /// The difficulty level which the current beatmap is considered to be. /// public DifficultyRating InterpretedDifficulty; - public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus) + /// + /// The current beatmap being checked. + /// + public readonly VerifiedBeatmap CurrentDifficulty; + + /// + /// Other beatmaps in the same beatmapset. + /// + public readonly IReadOnlyList OtherDifficulties; + + /// + /// All beatmaps in the same beatmapset. + /// + public IEnumerable AllDifficulties => OtherDifficulties.Prepend(CurrentDifficulty); + + public BeatmapVerifierContext(VerifiedBeatmap currentDifficulty, IReadOnlyList otherDifficulties, DifficultyRating difficultyRating) { - Beatmap = beatmap; - WorkingBeatmap = workingBeatmap; + CurrentDifficulty = currentDifficulty; InterpretedDifficulty = difficultyRating; + OtherDifficulties = otherDifficulties; + } + + /// + /// Backwards-compatible constructor that allows creating a context from a single playable and working beatmap. + /// + public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus) + : this(new VerifiedBeatmap(workingBeatmap, beatmap), [], difficultyRating) + { + } + + public static BeatmapVerifierContext Create(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus, BeatmapManager? beatmapManager = null) + { + var beatmapSet = beatmap.BeatmapInfo.BeatmapSet; + + var current = new VerifiedBeatmap(workingBeatmap, beatmap); + + if (beatmapSet?.Beatmaps == null || beatmapSet.Beatmaps.Count == 1) + return new BeatmapVerifierContext(current, [], difficultyRating); + + var others = new List(); + + foreach (var info in beatmapSet.Beatmaps) + { + if (info.Equals(beatmap.BeatmapInfo)) + continue; + + var otherWorking = beatmapManager?.GetWorkingBeatmap(info); + var otherPlayable = otherWorking?.GetPlayableBeatmap(info.Ruleset); + + if (otherWorking != null && otherPlayable != null) + others.Add(new VerifiedBeatmap(otherWorking, otherPlayable)); + } + + return new BeatmapVerifierContext(current, others, difficultyRating); } } } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs index 38976dd4b5..6c400c5de8 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs @@ -6,9 +6,9 @@ using System.Collections.Generic; using System.IO; using osu.Framework.Logging; using osu.Game.Beatmaps; -using osu.Game.IO.FileAbstraction; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Storyboards; +using osu.Game.Utils; using TagLib; using File = TagLib.File; @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckAudioInVideo : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Audio track in video files"); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Audio track in video files", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -27,10 +27,10 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; var videoPaths = new List(); - foreach (var layer in context.WorkingBeatmap.Storyboard.Layers) + foreach (var layer in context.CurrentDifficulty.Working.Storyboard.Layers) { foreach (var element in layer.Elements) { @@ -60,8 +60,8 @@ namespace osu.Game.Rulesets.Edit.Checks try { // We use TagLib here for platform invariance; BASS cannot detect audio presence on Linux. - using (Stream data = context.WorkingBeatmap.GetStream(storagePath)) - using (File tagFile = File.Create(new StreamFileAbstraction(filename, data))) + using (Stream data = context.CurrentDifficulty.Working.GetStream(storagePath)) + using (File tagFile = TagLibUtils.GetTagLibFile(filename, data)) { if (tagFile.Properties.AudioChannels == 0) continue; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs index 440d4e8e62..26021716a0 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using ManagedBass; using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Rulesets.Edit.Checks @@ -9,14 +10,15 @@ namespace osu.Game.Rulesets.Edit.Checks public class CheckAudioQuality : ICheck { // This is a requirement as stated in the Ranking Criteria. - // See https://osu.ppy.sh/wiki/en/Ranking_Criteria#rules.4 - private const int max_bitrate = 192; + // See https://osu.ppy.sh/wiki/en/Ranking_criteria#audio + private const int max_bitrate_default = 192; + private const int max_bitrate_ogg = 208; // "A song's audio file /.../ must be of reasonable quality. Try to find the highest quality source file available" // There not existing a version with a bitrate of 128 kbps or higher is extremely rare. private const int min_bitrate = 128; - public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Too high or low audio bitrate"); + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Too high or low audio bitrate", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -27,18 +29,25 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - string audioFile = context.Beatmap.Metadata.AudioFile; + string audioFile = context.CurrentDifficulty.Playable.Metadata.AudioFile; if (string.IsNullOrEmpty(audioFile)) yield break; - var track = context.WorkingBeatmap.Track; + var track = context.CurrentDifficulty.Working.Track; if (track?.Bitrate == null || track.Bitrate.Value == 0) yield return new IssueTemplateNoBitrate(this).Create(); - else if (track.Bitrate.Value > max_bitrate) - yield return new IssueTemplateTooHighBitrate(this).Create(track.Bitrate.Value); - else if (track.Bitrate.Value < min_bitrate) - yield return new IssueTemplateTooLowBitrate(this).Create(track.Bitrate.Value); + else + { + // Determine max bitrate based on audio format + var audioFormat = AudioCheckUtils.GetAudioFormatFromFile(context, audioFile); + int upperBitrateLimit = audioFormat.HasFlag(ChannelType.OGG) ? max_bitrate_ogg : max_bitrate_default; + + if (track.Bitrate.Value > upperBitrateLimit) + yield return new IssueTemplateTooHighBitrate(this).Create(track.Bitrate.Value, upperBitrateLimit); + else if (track.Bitrate.Value < min_bitrate) + yield return new IssueTemplateTooLowBitrate(this).Create(track.Bitrate.Value); + } } public class IssueTemplateTooHighBitrate : IssueTemplate @@ -48,7 +57,7 @@ namespace osu.Game.Rulesets.Edit.Checks { } - public Issue Create(int bitrate) => new Issue(this, bitrate, max_bitrate); + public Issue Create(int bitrate, int maxBitrate) => new Issue(this, bitrate, maxBitrate); } public class IssueTemplateTooLowBitrate : IssueTemplate diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs index 5008c13d9a..1147ff9663 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Edit.Checks private const int low_width = 960; private const int low_height = 540; - public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Too high or low background resolution"); + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Too high or low background resolution", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -33,11 +33,11 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - string backgroundFile = context.Beatmap.Metadata.BackgroundFile; + string backgroundFile = context.CurrentDifficulty.Playable.Metadata.BackgroundFile; if (string.IsNullOrEmpty(backgroundFile)) yield break; - var texture = context.WorkingBeatmap.GetBackground(); + var texture = context.CurrentDifficulty.Working.GetBackground(); if (texture == null) yield break; @@ -49,9 +49,9 @@ namespace osu.Game.Rulesets.Edit.Checks else if (texture.Width < low_width || texture.Height < low_height) yield return new IssueTemplateLowResolution(this).Create(texture.Width, texture.Height); - string? storagePath = context.Beatmap.BeatmapInfo.BeatmapSet?.GetPathForFile(backgroundFile); + string? storagePath = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet?.GetPathForFile(backgroundFile); - using (Stream stream = context.WorkingBeatmap.GetStream(storagePath)) + using (Stream stream = context.CurrentDifficulty.Working.GetStream(storagePath)) { double filesizeMb = stream.Length / (1024d * 1024d); diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs b/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs index f7be36beab..e16629d760 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs @@ -25,10 +25,10 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var startTimes = context.Beatmap.HitObjects.Select(ho => ho.StartTime).Order().ToList(); - var endTimes = context.Beatmap.HitObjects.Select(ho => ho.GetEndTime()).Order().ToList(); + var startTimes = context.CurrentDifficulty.Playable.HitObjects.Select(ho => ho.StartTime).Order().ToList(); + var endTimes = context.CurrentDifficulty.Playable.HitObjects.Select(ho => ho.GetEndTime()).Order().ToList(); - foreach (var breakPeriod in context.Beatmap.Breaks) + foreach (var breakPeriod in context.CurrentDifficulty.Playable.Breaks) { if (breakPeriod.Duration < BreakPeriod.MIN_BREAK_DURATION) yield return new IssueTemplateTooShort(this).Create(breakPeriod.StartTime); diff --git a/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs index ba5fbcf58d..75b5b08c7f 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; +using System; namespace osu.Game.Rulesets.Edit.Checks { @@ -12,18 +12,19 @@ namespace osu.Game.Rulesets.Edit.Checks { // We guarantee that the objects are either treated as concurrent or unsnapped when near the same beat divisor. private const double ms_leniency = CheckUnsnappedObjects.UNSNAP_MS_THRESHOLD; + private const double almost_concurrent_threshold = 10.0; public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Concurrent hitobjects"); public IEnumerable PossibleTemplates => new IssueTemplate[] { - new IssueTemplateConcurrentSame(this), - new IssueTemplateConcurrentDifferent(this) + new IssueTemplateConcurrent(this), + new IssueTemplateAlmostConcurrent(this) }; - public IEnumerable Run(BeatmapVerifierContext context) + public virtual IEnumerable Run(BeatmapVerifierContext context) { - var hitObjects = context.Beatmap.HitObjects; + var hitObjects = context.CurrentDifficulty.Playable.HitObjects; for (int i = 0; i < hitObjects.Count - 1; ++i) { @@ -33,56 +34,67 @@ namespace osu.Game.Rulesets.Edit.Checks { var nextHitobject = hitObjects[j]; - // Accounts for rulesets with hitobjects separated by columns, such as Mania. - // In these cases we only care about concurrent objects within the same column. - if ((hitobject as IHasColumn)?.Column != (nextHitobject as IHasColumn)?.Column) - continue; - // Two hitobjects cannot be concurrent without also being concurrent with all objects in between. - // So if the next object is not concurrent, then we know no future objects will be either. - if (!areConcurrent(hitobject, nextHitobject)) + // So if the next object is not concurrent or almost concurrent, then we know no future objects will be either. + if (!AreConcurrent(hitobject, nextHitobject) && !AreAlmostConcurrent(hitobject, nextHitobject)) break; - if (hitobject.GetType() == nextHitobject.GetType()) - yield return new IssueTemplateConcurrentSame(this).Create(hitobject, nextHitobject); - else - yield return new IssueTemplateConcurrentDifferent(this).Create(hitobject, nextHitobject); + if (AreConcurrent(hitobject, nextHitobject)) + { + yield return new IssueTemplateConcurrent(this).Create(hitobject, nextHitobject); + } + else if (AreAlmostConcurrent(hitobject, nextHitobject)) + { + yield return new IssueTemplateAlmostConcurrent(this).Create(hitobject, nextHitobject); + } } } } - private bool areConcurrent(HitObject hitobject, HitObject nextHitobject) => nextHitobject.StartTime <= hitobject.GetEndTime() + ms_leniency; + protected bool AreConcurrent(HitObject hitobject, HitObject nextHitobject) => nextHitobject.StartTime <= hitobject.GetEndTime() + ms_leniency; - public abstract class IssueTemplateConcurrent : IssueTemplate + protected bool AreAlmostConcurrent(HitObject hitobject, HitObject nextHitobject) => + Math.Abs(nextHitobject.StartTime - hitobject.GetEndTime()) < almost_concurrent_threshold; + + public class IssueTemplateConcurrent : IssueTemplate { - protected IssueTemplateConcurrent(ICheck check, string unformattedMessage) - : base(check, IssueType.Problem, unformattedMessage) + public IssueTemplateConcurrent(ICheck check) + : base(check, IssueType.Problem, "{0}") { } public Issue Create(HitObject hitobject, HitObject nextHitobject) { var hitobjects = new List { hitobject, nextHitobject }; - return new Issue(hitobjects, this, hitobject.GetType().Name, nextHitobject.GetType().Name) + string message = hitobject.GetType() == nextHitobject.GetType() + ? $"{hitobject.GetType().Name}s are concurrent here." + : $"{hitobject.GetType().Name} and {nextHitobject.GetType().Name} are concurrent here."; + + return new Issue(hitobjects, this, message) { Time = nextHitobject.StartTime }; } } - public class IssueTemplateConcurrentSame : IssueTemplateConcurrent + public class IssueTemplateAlmostConcurrent : IssueTemplate { - public IssueTemplateConcurrentSame(ICheck check) - : base(check, "{0}s are concurrent here.") + public IssueTemplateAlmostConcurrent(ICheck check) + : base(check, IssueType.Problem, "{0}") { } - } - public class IssueTemplateConcurrentDifferent : IssueTemplateConcurrent - { - public IssueTemplateConcurrentDifferent(ICheck check) - : base(check, "{0} and {1} are concurrent here.") + public Issue Create(HitObject hitobject, HitObject nextHitobject) { + var hitobjects = new List { hitobject, nextHitobject }; + string message = hitobject.GetType() == nextHitobject.GetType() + ? $"{hitobject.GetType().Name}s are less than {almost_concurrent_threshold}ms apart." + : $"{hitobject.GetType().Name} and {nextHitobject.GetType().Name} are less than {almost_concurrent_threshold}ms apart."; + + return new Issue(hitobjects, this, message) + { + Time = nextHitobject.StartTime + }; } } } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs index d6cd4f4caa..4f64f8838f 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckDelayedHitsounds.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Edit.Checks private const int delay_threshold = 5; private const int delay_threshold_negligible = 1; - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Delayed hit sounds."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Delayed hit sounds.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -37,14 +37,14 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; if (beatmapSet == null) yield break; foreach (var file in beatmapSet.Files) { - using (Stream? stream = context.WorkingBeatmap.GetStream(file.File.GetStoragePath())) + using (Stream? stream = context.CurrentDifficulty.Working.GetStream(file.File.GetStoragePath())) { if (stream == null) continue; @@ -119,8 +119,8 @@ namespace osu.Game.Rulesets.Edit.Checks string bank = parts[0]; string sampleSet = parts[1]; - return HitSampleInfo.AllBanks.Contains(bank) - && HitSampleInfo.AllAdditions.Append(HitSampleInfo.HIT_NORMAL).Any(sampleSet.StartsWith); + return HitSampleInfo.ALL_BANKS.Contains(bank) + && HitSampleInfo.ALL_ADDITIONS.Append(HitSampleInfo.HIT_NORMAL).Any(sampleSet.StartsWith); } public class IssueTemplateConsequentDelay : IssueTemplate diff --git a/osu.Game/Rulesets/Edit/Checks/CheckDrainLength.cs b/osu.Game/Rulesets/Edit/Checks/CheckDrainLength.cs index ac65dfadff..3115f80e66 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckDrainLength.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckDrainLength.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - double drainTime = context.Beatmap.CalculateDrainLength(); + double drainTime = context.CurrentDifficulty.Playable.CalculateDrainLength(); if (drainTime < min_drain_threshold) yield return new IssueTemplateTooShort(this).Create((int)(drainTime / 1000)); diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs index 3358e81d5f..941cebdb4f 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs @@ -48,16 +48,16 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - if (!context.Beatmap.HitObjects.Any()) + if (!context.CurrentDifficulty.Playable.HitObjects.Any()) yield break; mapHasHitsounds = false; objectsWithoutHitsounds = 0; - lastHitsoundTime = context.Beatmap.HitObjects.First().StartTime; + lastHitsoundTime = context.CurrentDifficulty.Playable.HitObjects.First().StartTime; var hitObjectsIncludingNested = new List(); - foreach (var hitObject in context.Beatmap.HitObjects) + foreach (var hitObject in context.CurrentDifficulty.Playable.HitObjects) { // Samples play on the end of objects. Some objects have nested objects to accomplish playing them elsewhere (e.g. slider head/repeat). foreach (var nestedHitObject in hitObject.NestedHitObjects) @@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Edit.Checks ++objectsWithoutHitsounds; } - private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.AllAdditions.Any(sample.Name.Contains); + private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.ALL_ADDITIONS.Any(sample.Name.Contains); private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL); public abstract class IssueTemplateLongPeriod : IssueTemplate diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs index 9a921ba808..158811044c 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Edit.Checks protected abstract string TypeOfFile { get; } protected abstract string? GetFilename(IBeatmap beatmap); - public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}"); + public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - string? filename = GetFilename(context.Beatmap); + string? filename = GetFilename(context.CurrentDifficulty.Playable); if (string.IsNullOrEmpty(filename)) { @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Edit.Checks } // If the file is set, also make sure it still exists. - string? storagePath = context.Beatmap.BeatmapInfo.BeatmapSet?.GetPathForFile(filename); + string? storagePath = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet?.GetPathForFile(filename); if (storagePath != null) yield break; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs b/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs index 9b6a861358..d28de45fe9 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckHitsoundsFormat.cs @@ -7,13 +7,14 @@ using ManagedBass; using osu.Framework.Audio.Callbacks; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.Models; using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Rulesets.Edit.Checks { public class CheckHitsoundsFormat : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for hitsound formats."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for hitsound formats.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -23,16 +24,25 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; - var audioFile = beatmapSet?.GetFile(context.Beatmap.Metadata.AudioFile); + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; if (beatmapSet == null) yield break; + // Collect all audio files from all difficulties to exclude them from the check, as they aren't hitsounds. + var audioFiles = new HashSet(ReferenceEqualityComparer.Instance); + + foreach (var difficulty in context.AllDifficulties) + { + var audioFile = beatmapSet.GetFile(difficulty.Playable.Metadata.AudioFile); + if (audioFile != null) + audioFiles.Add(audioFile); + } + foreach (var file in beatmapSet.Files) { - if (audioFile != null && ReferenceEquals(file.File, audioFile.File)) continue; + if (audioFiles.Contains(file)) continue; - using (Stream data = context.WorkingBeatmap.GetStream(file.File.GetStoragePath())) + using (Stream data = context.CurrentDifficulty.Working.GetStream(file.File.GetStoragePath())) { if (data == null) continue; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckInconsistentAudio.cs b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentAudio.cs new file mode 100644 index 0000000000..19f272244e --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentAudio.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckInconsistentAudio : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Inconsistent audio files", CheckScope.BeatmapSet); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateInconsistentAudio(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (context.AllDifficulties.Count() <= 1) + yield break; + + var referenceBeatmap = context.CurrentDifficulty.Playable; + string referenceAudioFile = referenceBeatmap.Metadata.AudioFile; + + foreach (var beatmap in context.OtherDifficulties) + { + string currentAudioFile = beatmap.Playable.Metadata.AudioFile; + + if (referenceAudioFile != currentAudioFile) + { + yield return new IssueTemplateInconsistentAudio(this).Create( + string.IsNullOrEmpty(referenceAudioFile) ? "not set" : referenceAudioFile, + beatmap.Playable.BeatmapInfo.DifficultyName, + string.IsNullOrEmpty(currentAudioFile) ? "not set" : currentAudioFile + ); + } + } + } + + public class IssueTemplateInconsistentAudio : IssueTemplate + { + public IssueTemplateInconsistentAudio(ICheck check) + : base(check, IssueType.Problem, "Inconsistent audio file between this difficulty ({0}) and \"{1}\" ({2}).") + { + } + + public Issue Create(string referenceAudio, string otherDifficulty, string otherAudio) + => new Issue(this, referenceAudio, otherDifficulty, otherAudio); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs new file mode 100644 index 0000000000..fdb72a3871 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckInconsistentMetadata : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Inconsistent metadata", CheckScope.BeatmapSet); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateInconsistentTags(this), + new IssueTemplateInconsistentOtherFields(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (context.AllDifficulties.Count() <= 1) + yield break; + + var referenceBeatmap = context.CurrentDifficulty.Playable; + var referenceMetadata = referenceBeatmap.Metadata; + + // Define metadata fields to check + var fieldsToCheck = new (string fieldName, Func fieldSelector)[] + { + ("artist", m => m.Artist), + ("unicode artist", m => m.ArtistUnicode), + ("title", m => m.Title), + ("unicode title", m => m.TitleUnicode), + ("source", m => m.Source), + ("creator", m => m.Author.Username) + }; + + foreach (var beatmap in context.OtherDifficulties) + { + var currentMetadata = beatmap.Playable.Metadata; + + // Check each metadata field for inconsistencies + foreach ((string fieldName, var fieldSelector) in fieldsToCheck) + { + string referenceField = fieldSelector(referenceMetadata); + string currentField = fieldSelector(currentMetadata); + + if (referenceField != currentField) + { + yield return new IssueTemplateInconsistentOtherFields(this).Create( + fieldName, + referenceBeatmap.BeatmapInfo.DifficultyName, + beatmap.Playable.BeatmapInfo.DifficultyName, + referenceField, + currentField + ); + } + } + + // Special handling for tags + if (referenceMetadata.Tags != currentMetadata.Tags) + { + var differenceTags = referenceMetadata.Tags.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); + differenceTags.SymmetricExceptWith(currentMetadata.Tags.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + string difference = string.Join(" ", differenceTags); + + if (!string.IsNullOrEmpty(difference)) + { + yield return new IssueTemplateInconsistentTags(this).Create( + referenceBeatmap.BeatmapInfo.DifficultyName, + beatmap.Playable.BeatmapInfo.DifficultyName, + difference + ); + } + } + } + } + + public class IssueTemplateInconsistentTags : IssueTemplate + { + public IssueTemplateInconsistentTags(ICheck check) + : base(check, IssueType.Problem, "Inconsistent tags between \"{0}\" and \"{1}\", difference being \"{2}\".") + { + } + + public Issue Create(string referenceDifficulty, string currentDifficulty, string difference) + => new Issue(this, referenceDifficulty, currentDifficulty, difference); + } + + public class IssueTemplateInconsistentOtherFields : IssueTemplate + { + public IssueTemplateInconsistentOtherFields(ICheck check) + : base(check, IssueType.Problem, "Inconsistent {0} fields between \"{1}\" and \"{2}\"; \"{3}\" and \"{4}\" respectively.") + { + } + + public Issue Create(string fieldName, string referenceDifficulty, string currentDifficulty, string referenceValue, string currentValue) + => new Issue(this, fieldName, referenceDifficulty, currentDifficulty, referenceValue, currentValue); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckInconsistentSettings.cs b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentSettings.cs new file mode 100644 index 0000000000..caab0c5ff4 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentSettings.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 System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckInconsistentSettings : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Settings, "Inconsistent settings", CheckScope.BeatmapSet); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateInconsistentSetting(this, IssueType.Warning), + new IssueTemplateInconsistentSetting(this, IssueType.Negligible) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (context.AllDifficulties.Count() <= 1) + return []; + + var referenceBeatmap = context.CurrentDifficulty.Playable; + + bool hasStoryboard = ResourcesCheckUtils.HasAnyStoryboardElementPresent(context.CurrentDifficulty.Working); + + var issues = new List(); + + // Define fields to check + checkIssue(IssueType.Warning, "Audio lead-in", b => b.AudioLeadIn); + checkIssue(IssueType.Warning, "Countdown", b => b.Countdown); + checkIssue(IssueType.Warning, "Countdown offset", b => b.CountdownOffset); + checkIssue(IssueType.Warning, "Epilepsy warning", b => b.EpilepsyWarning); + checkIssue(IssueType.Warning, "Letterbox during breaks", b => b.LetterboxInBreaks); + checkIssue(IssueType.Warning, "Samples match playback rate", b => b.SamplesMatchPlaybackRate); + + if (hasStoryboard) + checkIssue(IssueType.Warning, "Widescreen support", b => b.WidescreenStoryboard); + + checkIssue(IssueType.Negligible, "Tick Rate", b => b.Difficulty.SliderTickRate); + return issues; + + void checkIssue(IssueType issueType, string fieldName, Func fieldSelector) + where T : notnull // ideally this'd be `T : IEquatable` but `Enum` doesn't implement it... + { + var referenceValue = fieldSelector(referenceBeatmap); + + foreach (var beatmap in context.OtherDifficulties) + { + var currentValue = fieldSelector(beatmap.Playable); + + if (!EqualityComparer.Default.Equals(currentValue, referenceValue)) + { + issues.Add(new IssueTemplateInconsistentSetting(this, issueType).Create( + fieldName, + referenceBeatmap.BeatmapInfo.DifficultyName, + beatmap.Playable.BeatmapInfo.DifficultyName, + referenceValue.ToString() ?? string.Empty, + currentValue.ToString() ?? string.Empty + )); + } + } + } + } + + public class IssueTemplateInconsistentSetting : IssueTemplate + { + public IssueTemplateInconsistentSetting(ICheck check, IssueType issueType) + : base(check, issueType, "Inconsistent \"{0}\" setting between \"{1}\" and \"{2}\"; \"{3}\" and \"{4}\" respectively.") + { + } + + public Issue Create(string fieldName, string referenceDifficulty, string currentDifficulty, string referenceValue, string currentValue) + => new Issue(this, fieldName, referenceDifficulty, currentDifficulty, referenceValue, currentValue); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckInconsistentTimingControlPoints.cs b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentTimingControlPoints.cs new file mode 100644 index 0000000000..d30f575fc7 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckInconsistentTimingControlPoints.cs @@ -0,0 +1,144 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckInconsistentTimingControlPoints : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Timing, "Inconsistent timing control points", CheckScope.BeatmapSet); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateMissingTimingPoint(this), + new IssueTemplateExtraTimingPoint(this), + new IssueTemplateMissingTimingPointMinor(this), + new IssueTemplateInconsistentMeter(this), + new IssueTemplateInconsistentBPM(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (context.AllDifficulties.Count() <= 1) + yield break; + + // Use the current difficulty as reference + var referenceBeatmap = context.CurrentDifficulty.Playable; + var referenceTimingPoints = referenceBeatmap.ControlPointInfo.TimingPoints; + + foreach (var beatmap in context.OtherDifficulties) + { + var timingPoints = beatmap.Playable.ControlPointInfo.TimingPoints; + + // Check each timing point in the reference against this difficulty + foreach (var referencePoint in referenceTimingPoints) + { + var matchingPoint = TimingCheckUtils.FindMatchingTimingPoint(timingPoints, referencePoint.Time); + var exactMatchingPoint = TimingCheckUtils.FindExactMatchingTimingPoint(timingPoints, referencePoint.Time); + + string currentDifficultyName = beatmap.Playable.BeatmapInfo.DifficultyName; + + if (matchingPoint == null) + { + yield return new IssueTemplateMissingTimingPoint(this).Create(referencePoint.Time, currentDifficultyName); + } + else + { + // Check for meter signature inconsistency + if (!referencePoint.TimeSignature.Equals(matchingPoint.TimeSignature)) + { + yield return new IssueTemplateInconsistentMeter(this).Create(referencePoint.Time, currentDifficultyName); + } + + // Check for BPM inconsistency + if (Math.Abs(referencePoint.BeatLength - matchingPoint.BeatLength) > TimingCheckUtils.TIME_OFFSET_TOLERANCE_MS) + { + yield return new IssueTemplateInconsistentBPM(this).Create(referencePoint.Time, currentDifficultyName); + } + + // Check for exact timing match (decimal precision) + if (exactMatchingPoint == null) + { + yield return new IssueTemplateMissingTimingPointMinor(this).Create(referencePoint.Time, currentDifficultyName); + } + } + } + + // Check timing points in this difficulty that aren't in the reference + foreach (var timingPoint in timingPoints) + { + var matchingReferencePoint = TimingCheckUtils.FindMatchingTimingPoint(referenceTimingPoints, timingPoint.Time); + var exactMatchingReferencePoint = TimingCheckUtils.FindExactMatchingTimingPoint(referenceTimingPoints, timingPoint.Time); + + if (matchingReferencePoint == null) + { + yield return new IssueTemplateExtraTimingPoint(this).Create(timingPoint.Time, beatmap.Playable.BeatmapInfo.DifficultyName); + } + else if (exactMatchingReferencePoint == null) + { + yield return new IssueTemplateMissingTimingPointMinor(this).Create(timingPoint.Time, beatmap.Playable.BeatmapInfo.DifficultyName); + } + } + } + } + + public class IssueTemplateMissingTimingPoint : IssueTemplate + { + public IssueTemplateMissingTimingPoint(ICheck check) + : base(check, IssueType.Problem, "Missing timing control point in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateExtraTimingPoint : IssueTemplate + { + public IssueTemplateExtraTimingPoint(ICheck check) + : base(check, IssueType.Problem, "Extra timing control point in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateMissingTimingPointMinor : IssueTemplate + { + public IssueTemplateMissingTimingPointMinor(ICheck check) + : base(check, IssueType.Negligible, "Timing control point has decimally different offset in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateInconsistentMeter : IssueTemplate + { + public IssueTemplateInconsistentMeter(ICheck check) + : base(check, IssueType.Problem, "Inconsistent time signature in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + + public class IssueTemplateInconsistentBPM : IssueTemplate + { + public IssueTemplateInconsistentBPM(ICheck check) + : base(check, IssueType.Problem, "Inconsistent BPM in {0}.") + { + } + + public Issue Create(double time, string difficultyName) + => new Issue(time, this, difficultyName); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs new file mode 100644 index 0000000000..7a17eac3ea --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public abstract class CheckLowestDiffDrainTime : ICheck + { + /// + /// Defines the minimum drain time thresholds for different difficulty ratings. + /// + protected abstract IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds(); + + private const double break_time_leniency = 30 * 1000; + + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Spread, "Lowest difficulty too difficult for the given drain/play time(s)"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooShort(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + // Filter to only include difficulties with the same ruleset as the current one + var difficulties = context.AllDifficulties + .Where(d => d.Playable.BeatmapInfo.Ruleset.Equals(context.CurrentDifficulty.Playable.BeatmapInfo.Ruleset)) + .ToList(); + + if (difficulties.Count == 0) + yield break; + + var lowestDifficulty = difficulties.OrderBy(b => b.Playable.BeatmapInfo.StarRating).First(); + + // Get difficulty rating for the lowest difficulty + DifficultyRating lowestDifficultyRating = lowestDifficulty.Playable == context.CurrentDifficulty.Playable + ? context.InterpretedDifficulty + : StarDifficulty.GetDifficultyRating(lowestDifficulty.Playable.BeatmapInfo.StarRating); + + double drainTime = context.CurrentDifficulty.Playable.CalculateDrainLength(); + double playTime = context.CurrentDifficulty.Playable.CalculatePlayableLength(); + + bool isHighestDifficulty = difficulties.OrderByDescending(b => b.Playable.BeatmapInfo.StarRating).First() == context.CurrentDifficulty; + + // Use play time unless it's the highest difficulty and has significant breaks + bool canUsePlayTime = !isHighestDifficulty || context.CurrentDifficulty.Playable.TotalBreakTime < break_time_leniency; + + double effectiveTime = canUsePlayTime ? playTime : drainTime; + double thresholdReduction = canUsePlayTime ? 0 : break_time_leniency; + + // Check against thresholds based on the lowest difficulty's rating in the beatmapset + // Find the most appropriate threshold (highest rating that applies) + var applicableThreshold = GetThresholds() + .Where(t => lowestDifficultyRating >= t.rating) + .OrderByDescending(t => t.rating) + .FirstOrDefault(); + + if (applicableThreshold != default && effectiveTime < applicableThreshold.thresholdMs - thresholdReduction) + { + yield return new IssueTemplateTooShort(this).Create( + applicableThreshold.name, + canUsePlayTime ? "play" : "drain", + applicableThreshold.thresholdMs - thresholdReduction, + effectiveTime + ); + } + } + + public class IssueTemplateTooShort : IssueTemplate + { + public IssueTemplateTooShort(ICheck check) + : base(check, IssueType.Problem, "With the lowest difficulty being \"{0}\", the {1} time of this difficulty must be at least {2}, currently {3}.") + { + } + + public Issue Create(string lowestDiffLevel, string timeType, double requiredTime, double currentTime) + => new Issue(this, + lowestDiffLevel, + timeType, + TimeSpan.FromMilliseconds(requiredTime).ToString(@"m\:ss"), + TimeSpan.FromMilliseconds(currentTime).ToString(@"m\:ss")); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckMissingGenreLanguage.cs b/osu.Game/Rulesets/Edit/Checks/CheckMissingGenreLanguage.cs new file mode 100644 index 0000000000..e70aa3831b --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckMissingGenreLanguage.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions; +using osu.Game.Overlays.BeatmapListing; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckMissingGenreLanguage : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Missing Genre/Language Tags", CheckScope.BeatmapSet); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateMissingGenre(this), + new IssueTemplateMissingLanguage(this), + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var metadata = context.CurrentDifficulty.Playable.BeatmapInfo.Metadata; + + string tags = metadata.Tags.ToLowerInvariant(); + + if (!hasTags(tags)) + yield return new IssueTemplateMissingGenre(this).Create(); + + if (!hasTags(tags)) + yield return new IssueTemplateMissingLanguage(this).Create(); + } + + private bool hasTags(string tags) + where T : struct, Enum + { + foreach (var value in Enum.GetValues()) + { + string[] words = value.GetDescription().ToLowerInvariant().Split(' '); + + if (words.All(tags.Contains)) + return true; + } + + return false; + } + + public class IssueTemplateMissingGenre : IssueTemplate + { + public IssueTemplateMissingGenre(ICheck check) + : base(check, IssueType.Problem, "Missing genre tag (\"rock\", \"pop\", \"electronic\", etc), ignore if none fit.") + { + } + + public Issue Create() => new Issue(this); + } + + public class IssueTemplateMissingLanguage : IssueTemplate + { + public IssueTemplateMissingLanguage(ICheck check) + : base(check, IssueType.Problem, "Missing language tag (\"english\", \"japanese\", \"instrumental\", etc), ignore if none fit.") + { + } + + public Issue Create() => new Issue(this); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs index a2ae1764dd..60f159bc9c 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - foreach (var hitObject in context.Beatmap.HitObjects) + foreach (var hitObject in context.CurrentDifficulty.Playable.HitObjects) { // Worth keeping in mind: The samples of an object always play at its end time. // Objects like spinners have no sound at its start because of this, while hold notes have nested objects to accomplish this. diff --git a/osu.Game/Rulesets/Edit/Checks/CheckPreviewTime.cs b/osu.Game/Rulesets/Edit/Checks/CheckPreviewTime.cs index d4f9c1feaf..8260f4c245 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckPreviewTime.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckPreviewTime.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Rulesets.Edit.Checks @@ -19,19 +18,15 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var diffList = context.Beatmap.BeatmapInfo.BeatmapSet?.Beatmaps ?? new List(); - int previewTime = context.Beatmap.BeatmapInfo.Metadata.PreviewTime; + int previewTime = context.CurrentDifficulty.Playable.BeatmapInfo.Metadata.PreviewTime; if (previewTime == -1) yield return new IssueTemplateHasNoPreviewTime(this).Create(); - foreach (var diff in diffList) + foreach (var beatmap in context.OtherDifficulties) { - if (diff.Equals(context.Beatmap.BeatmapInfo)) - continue; - - if (diff.Metadata.PreviewTime != previewTime) - yield return new IssueTemplatePreviewTimeConflict(this).Create(diff.DifficultyName, previewTime, diff.Metadata.PreviewTime); + if (beatmap.Playable.BeatmapInfo.Metadata.PreviewTime != previewTime) + yield return new IssueTemplatePreviewTimeConflict(this).Create(beatmap.Playable.BeatmapInfo.DifficultyName, previewTime, beatmap.Playable.BeatmapInfo.Metadata.PreviewTime); } } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs b/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs index dd01fe110a..3f3b95d95b 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs @@ -2,19 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.IO; using System.Linq; using ManagedBass; -using osu.Framework.Audio.Callbacks; using osu.Game.Beatmaps; -using osu.Game.Extensions; using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Rulesets.Edit.Checks { public class CheckSongFormat : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for song formats."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Checks for song formats.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -30,34 +27,24 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; - var audioFile = beatmapSet?.GetFile(context.Beatmap.Metadata.AudioFile); + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; + var audioFile = beatmapSet?.GetFile(context.CurrentDifficulty.Playable.Metadata.AudioFile); if (beatmapSet == null) yield break; if (audioFile == null) yield break; - using (Stream data = context.WorkingBeatmap.GetStream(audioFile.File.GetStoragePath())) + var audioFormat = AudioCheckUtils.GetAudioFormatFromFile(context, context.CurrentDifficulty.Playable.Metadata.AudioFile); + + // If the format is not supported by BASS + if (audioFormat == 0) { - if (data == null || data.Length <= 0) yield break; + yield return new IssueTemplateFormatUnsupported(this).Create(audioFile.Filename); - var fileCallbacks = new FileCallbacks(new DataStreamFileProcedures(data)); - int decodeStream = Bass.CreateStream(StreamSystem.NoBuffer, BassFlags.Decode, fileCallbacks.Callbacks, fileCallbacks.Handle); - - // If the format is not supported by BASS - if (decodeStream == 0) - { - yield return new IssueTemplateFormatUnsupported(this).Create(audioFile.Filename); - - yield break; - } - - var audioInfo = Bass.ChannelGetInfo(decodeStream); - - if (!allowedFormats.Contains(audioInfo.ChannelType)) - yield return new IssueTemplateIncorrectFormat(this).Create(audioFile.Filename); - - Bass.StreamFree(decodeStream); + yield break; } + + if (!allowedFormats.Contains(audioFormat)) + yield return new IssueTemplateIncorrectFormat(this).Create(audioFile.Filename); } public class IssueTemplateFormatUnsupported : IssueTemplate @@ -73,7 +60,7 @@ namespace osu.Game.Rulesets.Edit.Checks public class IssueTemplateIncorrectFormat : IssueTemplate { public IssueTemplateIncorrectFormat(ICheck check) - : base(check, IssueType.Problem, "\"{0}\" is using a incorrect format. Use mp3 or ogg for the song's audio.") + : base(check, IssueType.Problem, "\"{0}\" is using an incorrect format. Use mp3 or ogg for the song's audio.") { } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs b/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs index 9c702ad58a..58cfe558e5 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckTitleMarkers : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Checks for incorrect formats of (TV Size) / (Game Ver.) / (Short Ver.) / (Cut Ver.) / (Sped Up Ver.) / etc in title."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Checks for incorrect formats of (TV Size) / (Game Ver.) / (Short Ver.) / (Cut Ver.) / (Sped Up Ver.) / etc in title.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -31,8 +31,8 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - string romanisedTitle = context.Beatmap.Metadata.Title; - string unicodeTitle = context.Beatmap.Metadata.TitleUnicode; + string romanisedTitle = context.CurrentDifficulty.Playable.Metadata.Title; + string unicodeTitle = context.CurrentDifficulty.Playable.Metadata.TitleUnicode; foreach (var check in markerChecks) { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs index 3f85926e04..563e18848b 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Edit.Checks { private const int ms_threshold = 25; - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Too short audio files"); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Too short audio files", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -23,13 +23,13 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; if (beatmapSet != null) { foreach (var file in beatmapSet.Files) { - using (Stream data = context.WorkingBeatmap.GetStream(file.File.GetStoragePath())) + using (Stream data = context.CurrentDifficulty.Working.GetStream(file.File.GetStoragePath())) { if (data == null) continue; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs index ded1bb54ca..35900cdcd0 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs @@ -23,9 +23,9 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var controlPointInfo = context.Beatmap.ControlPointInfo; + var controlPointInfo = context.CurrentDifficulty.Playable.ControlPointInfo; - foreach (var hitobject in context.Beatmap.HitObjects) + foreach (var hitobject in context.CurrentDifficulty.Playable.HitObjects) { double startUnsnap = hitobject.StartTime - controlPointInfo.GetClosestSnappedTime(hitobject.StartTime); string startPostfix = hitobject is IHasDuration ? "start" : ""; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckUnusedAudioAtEnd.cs b/osu.Game/Rulesets/Edit/Checks/CheckUnusedAudioAtEnd.cs index 2e97fbeb99..a69dd324f9 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckUnusedAudioAtEnd.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckUnusedAudioAtEnd.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks.Components; -using osu.Game.Storyboards; namespace osu.Game.Rulesets.Edit.Checks { @@ -22,8 +21,8 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - double mappedLength = context.Beatmap.HitObjects.Any() ? context.Beatmap.GetLastObjectTime() : 0; - double trackLength = context.WorkingBeatmap.Track.Length; + double mappedLength = context.CurrentDifficulty.Playable.HitObjects.Any() ? context.CurrentDifficulty.Playable.GetLastObjectTime() : 0; + double trackLength = context.CurrentDifficulty.Working.Track.Length; double mappedPercentage = Math.Round(mappedLength / trackLength * 100); @@ -31,7 +30,7 @@ namespace osu.Game.Rulesets.Edit.Checks { double percentageLeft = Math.Abs(mappedPercentage - 100); - bool storyboardIsPresent = isAnyStoryboardElementPresent(context.WorkingBeatmap.Storyboard); + bool storyboardIsPresent = ResourcesCheckUtils.HasAnyStoryboardElementPresent(context.CurrentDifficulty.Working); if (storyboardIsPresent) { @@ -44,19 +43,6 @@ namespace osu.Game.Rulesets.Edit.Checks } } - private bool isAnyStoryboardElementPresent(Storyboard storyboard) - { - foreach (var layer in storyboard.Layers) - { - foreach (var _ in layer.Elements) - { - return true; - } - } - - return false; - } - public class IssueTemplateUnusedAudioAtEnd : IssueTemplate { public IssueTemplateUnusedAudioAtEnd(ICheck check) diff --git a/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs b/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs index 1b603b7e47..7a1e87954b 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckVideoResolution.cs @@ -6,9 +6,9 @@ using System.Collections.Generic; using System.IO; using osu.Framework.Logging; using osu.Game.Beatmaps; -using osu.Game.IO.FileAbstraction; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Storyboards; +using osu.Game.Utils; using TagLib; using File = TagLib.File; @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Edit.Checks private const int max_video_height = 720; - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Resources, "Too high video resolution."); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Resources, "Too high video resolution.", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -30,8 +30,8 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; - var videoPaths = getVideoPaths(context.WorkingBeatmap.Storyboard); + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; + var videoPaths = getVideoPaths(context.CurrentDifficulty.Working.Storyboard); foreach (string filename in videoPaths) { @@ -44,8 +44,8 @@ namespace osu.Game.Rulesets.Edit.Checks try { - using (Stream data = context.WorkingBeatmap.GetStream(storagePath)) - using (File tagFile = File.Create(new StreamFileAbstraction(filename, data))) + using (Stream data = context.CurrentDifficulty.Working.GetStream(storagePath)) + using (File tagFile = TagLibUtils.GetTagLibFile(filename, data)) { int height = tagFile.Properties.VideoHeight; int width = tagFile.Properties.VideoWidth; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckVideoUsage.cs b/osu.Game/Rulesets/Edit/Checks/CheckVideoUsage.cs new file mode 100644 index 0000000000..1384ffe4d4 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckVideoUsage.cs @@ -0,0 +1,137 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckVideoUsage : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Resources, "Inconsistent video usage", CheckScope.BeatmapSet); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateDifferentVideo(this), + new IssueTemplateDifferentStartTime(this), + new IssueTemplateMissingVideo(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var currentVideo = ResourcesCheckUtils.GetDifficultyVideo(context.CurrentDifficulty.Working); + + // If current difficulty has no video but any other does -> problem + if (currentVideo == null) + { + foreach (var otherDifficulty in context.OtherDifficulties) + { + if (ResourcesCheckUtils.GetDifficultyVideo(otherDifficulty.Working) != null) + { + yield return new IssueTemplateMissingVideo(this).Create(context.CurrentDifficulty.Playable.BeatmapInfo.DifficultyName); + + break; + } + } + } + + // If current has a video, check for missing video on other difficulties and warn about different files vs current. + if (currentVideo != null) + { + string referencePath = currentVideo.Path; + + foreach (var otherDifficulty in context.OtherDifficulties) + { + var otherVideo = ResourcesCheckUtils.GetDifficultyVideo(otherDifficulty.Working); + string difficultyName = otherDifficulty.Playable.BeatmapInfo.DifficultyName; + + // If other difficulty has no video -> problem + if (otherVideo == null) + { + yield return new IssueTemplateMissingVideo(this).Create(difficultyName); + + continue; + } + + // Different video used (relative to current) -> warning + if (!string.Equals(otherVideo.Path, referencePath, StringComparison.OrdinalIgnoreCase)) + { + yield return new IssueTemplateDifferentVideo(this).Create(difficultyName, referencePath, otherVideo.Path); + } + } + } + + // Pairwise check: for each video file used across all difficulties, ensure all start times match. + // Build a list of all difficulties with a video present (including current). + var allDifficultiesWithVideo = new List<(string DifficultyName, string Path, double StartTime)>(); + + if (currentVideo != null) + allDifficultiesWithVideo.Add((context.CurrentDifficulty.Playable.BeatmapInfo.DifficultyName, currentVideo.Path, currentVideo.StartTime)); + + foreach (var other in context.OtherDifficulties) + { + var video = ResourcesCheckUtils.GetDifficultyVideo(other.Working); + + if (video != null) + { + string name = other.Playable.BeatmapInfo.DifficultyName; + allDifficultiesWithVideo.Add((name, video.Path, video.StartTime)); + } + } + + // Group by video path (case-insensitive) and compare start times pairwise within each group. + foreach (var groupedByVideoPath in allDifficultiesWithVideo.GroupBy(v => v.Path, StringComparer.OrdinalIgnoreCase)) + { + var difficultiesWithSameVideo = groupedByVideoPath.ToList(); + + for (int i = 0; i < difficultiesWithSameVideo.Count; i++) + { + for (int j = i + 1; j < difficultiesWithSameVideo.Count; j++) + { + if (!difficultiesWithSameVideo[i].StartTime.Equals(difficultiesWithSameVideo[j].StartTime)) + { + yield return new IssueTemplateDifferentStartTime(this).Create( + groupedByVideoPath.Key, + difficultiesWithSameVideo[i].DifficultyName, difficultiesWithSameVideo[i].StartTime, + difficultiesWithSameVideo[j].DifficultyName, difficultiesWithSameVideo[j].StartTime); + } + } + } + } + } + + public class IssueTemplateDifferentVideo : IssueTemplate + { + public IssueTemplateDifferentVideo(ICheck check) + : base(check, IssueType.Warning, "Video file differs from current difficulty in \"{0}\" (current: \"{1}\", other: \"{2}\"). Ensure this makes sense.") + { + } + + public Issue Create(string otherDifficulty, string currentPath, string otherPath) + => new Issue(this, otherDifficulty, currentPath, otherPath); + } + + public class IssueTemplateDifferentStartTime : IssueTemplate + { + public IssueTemplateDifferentStartTime(ICheck check) + : base(check, IssueType.Problem, "Video start time differs for \"{0}\" between \"{1}\" ({2:0} ms) and \"{3}\" ({4:0} ms).") + { + } + + public Issue Create(string path, string difficultyA, double startA, string difficultyB, double startB) + => new Issue(this, path, difficultyA, startA, difficultyB, startB); + } + + public class IssueTemplateMissingVideo : IssueTemplate + { + public IssueTemplateMissingVideo(ICheck check) + : base(check, IssueType.Problem, "Video is missing in \"{0}\".") + { + } + + public Issue Create(string otherDifficulty) => new Issue(this, otherDifficulty); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs index 75cb08002f..d9de49a910 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Edit.Checks { public class CheckZeroByteFiles : ICheck { - public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Files, "Zero-byte files"); + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Files, "Zero-byte files", CheckScope.BeatmapSet); public IEnumerable PossibleTemplates => new IssueTemplate[] { @@ -19,13 +19,13 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; if (beatmapSet != null) { foreach (var file in beatmapSet.Files) { - using (Stream data = context.WorkingBeatmap.GetStream(file.File.GetStoragePath())) + using (Stream data = context.CurrentDifficulty.Working.GetStream(file.File.GetStoragePath())) { if (data?.Length == 0) yield return new IssueTemplateZeroBytes(this).Create(file.Filename); diff --git a/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs index b9be94736b..a10dd43e74 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - foreach (var hitObject in context.Beatmap.HitObjects) + foreach (var hitObject in context.CurrentDifficulty.Playable.HitObjects) { if (!(hitObject is IHasDuration hasDuration)) continue; diff --git a/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs index 8a35b84170..746f2bf256 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs @@ -3,6 +3,10 @@ using System.IO; using System.Linq; +using ManagedBass; +using osu.Framework.Audio.Callbacks; +using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Utils; namespace osu.Game.Rulesets.Edit.Checks.Components @@ -10,5 +14,46 @@ namespace osu.Game.Rulesets.Edit.Checks.Components public static class AudioCheckUtils { public static bool HasAudioExtension(string filename) => SupportedExtensions.AUDIO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant()); + + /// + /// Gets the audio format (ChannelType) from a stream using BASS. + /// + /// The audio file stream. + /// The ChannelType of the audio, or if detection fails. + public static ChannelType GetAudioFormat(Stream data) + { + if (data.Length <= 0) + return ChannelType.Unknown; + + using (var fileCallbacks = new FileCallbacks(new DataStreamFileProcedures(data))) + { + int decodeStream = Bass.CreateStream(StreamSystem.NoBuffer, BassFlags.Decode, fileCallbacks.Callbacks, fileCallbacks.Handle); + if (decodeStream == 0) + return ChannelType.Unknown; + + var audioInfo = Bass.ChannelGetInfo(decodeStream); + Bass.StreamFree(decodeStream); + + return audioInfo.ChannelType; + } + } + + /// + /// Gets the audio format for a specific file in a beatmapset. + /// + /// The beatmap verifier context. + /// The filename to check. + /// The ChannelType of the audio file, or if detection fails. + public static ChannelType GetAudioFormatFromFile(BeatmapVerifierContext context, string filename) + { + var beatmapSet = context.CurrentDifficulty.Playable.BeatmapInfo.BeatmapSet; + var audioFile = beatmapSet?.GetFile(filename); + + if (beatmapSet == null || audioFile == null) + return ChannelType.Unknown; + + using (Stream data = context.CurrentDifficulty.Working.GetStream(audioFile.File.GetStoragePath())) + return GetAudioFormat(data); + } } } diff --git a/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs b/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs index cebb2f5455..cbc07f1fa3 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs @@ -15,10 +15,16 @@ namespace osu.Game.Rulesets.Edit.Checks.Components /// public readonly string Description; - public CheckMetadata(CheckCategory category, string description) + /// + /// Specifies whether this check is difficulty-specific or applies to the entire beatmapset. Set to by default. + /// + public readonly CheckScope Scope; + + public CheckMetadata(CheckCategory category, string description, CheckScope scope = CheckScope.Difficulty) { Category = category; Description = description; + Scope = scope; } } } diff --git a/osu.Game/Rulesets/Edit/Checks/Components/CheckScope.cs b/osu.Game/Rulesets/Edit/Checks/Components/CheckScope.cs new file mode 100644 index 0000000000..7dcc4d87f2 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/CheckScope.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.Localisation; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public enum CheckScope + { + /// + /// Run checks that apply to the current difficulty. + /// + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.CheckCurrentDifficulty))] + Difficulty, + + /// + /// Run checks that apply to the beatmap set as a whole. + /// + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.CheckEntireBeatmapSet))] + BeatmapSet, + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs b/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs index 141de55f1d..9b49bd449e 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs @@ -13,17 +13,17 @@ namespace osu.Game.Rulesets.Edit.Checks.Components /// /// The metadata for this check. /// - public CheckMetadata Metadata { get; } + CheckMetadata Metadata { get; } /// /// All possible templates for issues that this check may return. /// - public IEnumerable PossibleTemplates { get; } + IEnumerable PossibleTemplates { get; } /// /// Runs this check and returns any issues detected for the provided beatmap. /// /// The beatmap verifier context associated with the beatmap. - public IEnumerable Run(BeatmapVerifierContext context); + IEnumerable Run(BeatmapVerifierContext context); } } diff --git a/osu.Game/Rulesets/Edit/Checks/Components/Issue.cs b/osu.Game/Rulesets/Edit/Checks/Components/Issue.cs index 2bc9930e8f..2cf75bbbb1 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/Issue.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/Issue.cs @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Edit.Checks.Components public string GetEditorTimestamp() { - return Time == null ? string.Empty : Time.Value.ToEditorFormattedString(); + return Time?.ToEditorFormattedString() ?? string.Empty; } } } diff --git a/osu.Game/Rulesets/Edit/Checks/Components/ResourcesCheckUtils.cs b/osu.Game/Rulesets/Edit/Checks/Components/ResourcesCheckUtils.cs new file mode 100644 index 0000000000..96bd11f1fc --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/ResourcesCheckUtils.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Storyboards; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public static class ResourcesCheckUtils + { + /// + /// Checks if any storyboard element is present in the working beatmap. + /// + /// The working beatmap to check. + /// True if any storyboard element is present, false otherwise. + public static bool HasAnyStoryboardElementPresent(IWorkingBeatmap workingBeatmap) + { + foreach (var layer in workingBeatmap.Storyboard.Layers) + { + foreach (var _ in layer.Elements) + { + return true; + } + } + + return false; + } + + /// + /// Retrieves the first storyboard video entry for the provided working beatmap, if any. + /// + /// The working beatmap to inspect. + /// + /// The first found, or null if none exists. + /// + public static StoryboardVideo? GetDifficultyVideo(IWorkingBeatmap workingBeatmap) + { + foreach (var layer in workingBeatmap.Storyboard.Layers) + { + foreach (var element in layer.Elements) + { + if (element is StoryboardVideo video) + return video; + } + } + + return null; + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/TimingCheckUtils.cs b/osu.Game/Rulesets/Edit/Checks/Components/TimingCheckUtils.cs new file mode 100644 index 0000000000..f56f27813d --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/TimingCheckUtils.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public static class TimingCheckUtils + { + // Tolerance for exact time offset matching (in milliseconds) + public const double TIME_OFFSET_TOLERANCE_MS = 0.01; + + /// + /// Finds a timing control point that starts at approximately the same time (within 1ms after rounding). + /// + /// The collection of timing points to search. + /// The time to match against. + /// The matching timing control point, or null if none found. + public static TimingControlPoint? FindMatchingTimingPoint(IEnumerable timingPoints, double time) + { + return timingPoints.FirstOrDefault(tp => (int)tp.Time == (int)time); + } + + /// + /// Finds a timing control point that starts at precisely the same time (within timing tolerance). + /// + /// The collection of timing points to search. + /// The time to match against. + /// The exact matching timing control point, or null if none found. + public static TimingControlPoint? FindExactMatchingTimingPoint(IEnumerable timingPoints, double time) + { + return timingPoints.FirstOrDefault(tp => Precision.AlmostEquals(tp.Time, time, TIME_OFFSET_TOLERANCE_MS)); + } + } +} diff --git a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index 7337a75509..2d6e09b3fd 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -14,9 +14,9 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; -using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.OSD; @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Edit private EditorClock editorClock { get; set; } = null!; [Resolved] - private EditorBeatmap editorBeatmap { get; set; } = null!; + protected EditorBeatmap EditorBeatmap { get; private set; } = null!; [Resolved] private IBeatSnapProvider beatSnapProvider { get; set; } = null!; @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Edit } }); - DistanceSpacingMultiplier.Value = editorBeatmap.DistanceSpacing; + DistanceSpacingMultiplier.Value = EditorBeatmap.DistanceSpacing; DistanceSpacingMultiplier.BindValueChanged(multiplier => { distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; @@ -109,7 +109,7 @@ namespace osu.Game.Rulesets.Edit if (multiplier.NewValue != multiplier.OldValue) onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); - editorBeatmap.DistanceSpacing = multiplier.NewValue; + EditorBeatmap.DistanceSpacing = multiplier.NewValue; }, true); DistanceSpacingMultiplier.BindDisabledChanged(disabled => distanceSpacingSlider.Alpha = disabled ? 0 : 1, true); @@ -191,9 +191,14 @@ namespace osu.Game.Rulesets.Edit } } - public IEnumerable CreateTernaryButtons() => new[] + public IEnumerable CreateTernaryButtons() => new[] { - new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = OsuIcon.EditorDistanceSnap }) + new DrawableTernaryButton + { + Current = DistanceSnapToggle, + Description = "Distance Snap", + CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorDistanceSnap }, + } }; public void HandleToggleViaKey(KeyboardEvent key) @@ -260,57 +265,38 @@ namespace osu.Game.Rulesets.Edit #region IDistanceSnapProvider - public virtual float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) + public virtual float GetBeatSnapDistance(IHasSliderVelocity? withVelocity = null) { - return (float)(100 * (useReferenceSliderVelocity && referenceObject is IHasSliderVelocity hasSliderVelocity ? hasSliderVelocity.SliderVelocityMultiplier : 1) * editorBeatmap.Difficulty.SliderMultiplier * 1 + return (float)(100 * (withVelocity?.SliderVelocityMultiplier ?? 1) * EditorBeatmap.Difficulty.SliderMultiplier * 1 / beatSnapProvider.BeatDivisor); } - public virtual float DurationToDistance(HitObject referenceObject, double duration) + public virtual float DurationToDistance(double duration, double timingReference, IHasSliderVelocity? withVelocity = null) { - double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); - return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceObject)); + double beatLength = beatSnapProvider.GetBeatLengthAtTime(timingReference); + return (float)(duration / beatLength * GetBeatSnapDistance(withVelocity)); } - public virtual double DistanceToDuration(HitObject referenceObject, float distance) + public virtual double DistanceToDuration(float distance, double timingReference, IHasSliderVelocity? withVelocity = null) { - double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); - return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength; + double beatLength = beatSnapProvider.GetBeatLengthAtTime(timingReference); + return distance / GetBeatSnapDistance(withVelocity) * beatLength; } - public virtual double FindSnappedDuration(HitObject referenceObject, float distance) - => beatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; - - public virtual float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) + public virtual float FindSnappedDistance(float distance, double snapReferenceTime, IHasSliderVelocity? withVelocity = null) { - double referenceTime; + double actualDuration = snapReferenceTime + DistanceToDuration(distance, snapReferenceTime, withVelocity); - switch (target) - { - case DistanceSnapTarget.Start: - referenceTime = referenceObject.StartTime; - break; + double snappedTime = beatSnapProvider.SnapTime(actualDuration, snapReferenceTime); - case DistanceSnapTarget.End: - referenceTime = referenceObject.GetEndTime(); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(target), target, $"Unknown {nameof(DistanceSnapTarget)} value"); - } - - double actualDuration = referenceTime + DistanceToDuration(referenceObject, distance); - - double snappedTime = beatSnapProvider.SnapTime(actualDuration, referenceTime); - - double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceTime); + double beatLength = beatSnapProvider.GetBeatLengthAtTime(snapReferenceTime); // we don't want to exceed the actual duration and snap to a point in the future. // as we are snapping to beat length via SnapTime (which will round-to-nearest), check for snapping in the forward direction and reverse it. if (snappedTime > actualDuration + 1) snappedTime -= beatLength; - return DurationToDistance(referenceObject, snappedTime - referenceTime); + return DurationToDistance(snappedTime - snapReferenceTime, snapReferenceTime, withVelocity); } #endregion @@ -326,9 +312,9 @@ namespace osu.Game.Rulesets.Edit } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(RealmKeyBindingStore keyBindingStore) { - ShortcutText.Text = config.LookupKeyBindings(getAction(change)).ToUpper(); + ShortcutText.Text = keyBindingStore.GetBindingsStringFor(getAction(change)).ToUpper(); } private static GlobalAction getAction(ValueChangedEvent change) => change.NewValue - change.OldValue > 0 diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index 8af795f880..55449ff4d9 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.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 System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Screens.Edit; @@ -21,6 +24,7 @@ namespace osu.Game.Rulesets.Edit private readonly Bindable contractSidebars = new Bindable(); private bool expandOnHover; + private OffsetMaintainingScrollContainer scrollContainer = null!; [Resolved] private Editor? editor { get; set; } @@ -42,6 +46,27 @@ namespace osu.Game.Rulesets.Edit config.BindWith(OsuSetting.EditorContractSidebars, contractSidebars); } + protected override OsuScrollContainer CreateScrollContainer() => scrollContainer = new OffsetMaintainingScrollContainer(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + var inputManager = GetContainingInputManager(); + + if (inputManager != null) + { + Expanded.BindValueChanged(_ => + { + // When state changes from expanded -> collapsed the mouse is no longer within the toolbox so there would be no + // hovered children if we used the mouse position directly + var position = new Vector2(ScreenSpaceDrawQuad.Centre.X, inputManager.CurrentState.Mouse.Position.Y); + + scrollContainer.TargetDrawable = Children.FirstOrDefault(it => it.Contains(position)); + }); + } + } + protected override void Update() { base.Update(); @@ -55,14 +80,52 @@ namespace osu.Game.Rulesets.Edit } } - protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && anyToolboxHovered(screenSpacePos); - - private bool anyToolboxHovered(Vector2 screenSpacePos) => FillFlow.ScreenSpaceDrawQuad.Contains(screenSpacePos); - protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) => true; + + private partial class OffsetMaintainingScrollContainer : OsuScrollContainer + { + private Drawable? targetDrawable; + private float targetPosition; + + public Drawable? TargetDrawable + { + get => targetDrawable; + set + { + targetDrawable = value; + + if (value != null) + targetPosition = ToLocalSpace(value.ScreenSpaceDrawQuad.TopLeft).Y; + } + } + + protected override void UpdateAfterChildren() + { + if (targetDrawable != null) + { + float currentPosition = ToLocalSpace(targetDrawable.ScreenSpaceDrawQuad.TopLeft).Y; + + if (!Precision.AlmostEquals(targetPosition, currentPosition)) + { + double offset = currentPosition - targetPosition; + + double scrollTarget = Math.Clamp(Current + offset, 0, ScrollableExtent); + + ScrollTo(scrollTarget, false, double.PositiveInfinity); + } + } + + base.UpdateAfterChildren(); + } + + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = null) + { + targetDrawable = null; + + base.OnUserScroll(value, animated, distanceDecay); + } + } } } diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 4b64548f9c..c138808890 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -261,18 +262,9 @@ namespace osu.Game.Rulesets.Edit .Select(t => new HitObjectCompositionToolButton(t, () => toolSelected(t))) .ToList(); - foreach (var item in toolboxCollection.Items) - { - item.Selected.DisabledChanged += isDisabled => - { - item.TooltipText = isDisabled ? "Add at least one timing point first!" : ((HitObjectCompositionToolButton)item).TooltipText; - }; - } + togglesCollection.AddRange(CreateTernaryButtons().ToArray()); - TernaryStates = CreateTernaryButtons().ToArray(); - togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); - - sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Zip(BlueprintContainer.SampleAdditionBankTernaryStates).Select(b => new SampleBankTernaryButton(b.First, b.Second))); + sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates); SetSelectTool(); @@ -368,20 +360,15 @@ namespace osu.Game.Rulesets.Edit /// protected abstract IReadOnlyList CompositionTools { get; } - /// - /// A collection of states which will be displayed to the user in the toolbox. - /// - public TernaryButton[] TernaryStates { get; private set; } - /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; + protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. /// - protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this); + protected abstract ComposeBlueprintContainer CreateBlueprintContainer(); protected virtual Drawable CreateHitObjectInspector() => new HitObjectInspector(); @@ -435,9 +422,9 @@ namespace osu.Game.Rulesets.Edit } else { - if (togglesCollection.ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) + if (togglesCollection.ChildrenOfType().ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) { - button.Button.Toggle(); + button.Toggle(); return true; } } @@ -571,28 +558,6 @@ namespace osu.Game.Rulesets.Edit /// The most relevant . protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield; - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) - { - var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); - double? targetTime = null; - - if (snapType.HasFlag(SnapType.GlobalGrids)) - { - if (playfield is ScrollingPlayfield scrollingPlayfield) - { - targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); - - // apply beat snapping - targetTime = BeatSnapProvider.SnapTime(targetTime.Value); - - // convert back to screen space - screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); - } - } - - return new SnapResult(screenSpacePosition, targetTime, playfield); - } - #endregion } @@ -601,7 +566,7 @@ namespace osu.Game.Rulesets.Edit /// Generally used to access certain methods without requiring a generic type for . /// [Cached] - public abstract partial class HitObjectComposer : CompositeDrawable, IPositionSnapProvider + public abstract partial class HitObjectComposer : CompositeDrawable { public const float TOOLBOX_CONTRACTED_SIZE_LEFT = 60; public const float TOOLBOX_CONTRACTED_SIZE_RIGHT = 120; @@ -644,11 +609,5 @@ namespace osu.Game.Rulesets.Edit /// The time instant to seek to, in milliseconds. /// The ruleset-specific description of objects to select at the given timestamp. public virtual void SelectFromTimestamp(double timestamp, string objectDescription) { } - - #region IPositionSnapProvider - - public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); - - #endregion } } diff --git a/osu.Game/Rulesets/Edit/HitObjectCompositionToolButton.cs b/osu.Game/Rulesets/Edit/HitObjectCompositionToolButton.cs index 641d60dbd3..65a0fb983a 100644 --- a/osu.Game/Rulesets/Edit/HitObjectCompositionToolButton.cs +++ b/osu.Game/Rulesets/Edit/HitObjectCompositionToolButton.cs @@ -16,7 +16,10 @@ namespace osu.Game.Rulesets.Edit { Tool = tool; - TooltipText = tool.TooltipText; + Selected.BindDisabledChanged(isDisabled => + { + TooltipText = isDisabled ? "Add at least one timing point first!" : Tool.TooltipText; + }, true); } } } diff --git a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs index 4df2a52743..6720540ec2 100644 --- a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; +using osuTK; namespace osu.Game.Rulesets.Edit { @@ -87,14 +88,13 @@ namespace osu.Game.Rulesets.Edit } /// - /// Updates the time and position of this based on the provided snap information. + /// Updates the time and position of this . /// - /// The snap result information. - public override void UpdateTimeAndPosition(SnapResult result) + public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double time) { if (PlacementActive == PlacementState.Waiting) { - HitObject.StartTime = result.Time ?? EditorClock.CurrentTime; + HitObject.StartTime = time; if (HitObject is IHasComboInformation comboInformation) comboInformation.UpdateComboInformation(getPreviousHitObject() as IHasComboInformation); @@ -129,6 +129,8 @@ namespace osu.Game.Rulesets.Edit for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) hasRepeats.NodeSamples[i] = HitObject.Samples.Select(o => o.With()).ToList(); } + + return new SnapResult(screenSpacePosition, time); } /// diff --git a/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs b/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs index 7fc9772598..f1aee34fef 100644 --- a/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs @@ -11,6 +11,6 @@ namespace osu.Game.Rulesets.Edit /// public interface IBeatmapVerifier { - public IEnumerable Run(BeatmapVerifierContext context); + IEnumerable Run(BeatmapVerifierContext context); } } diff --git a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs index 612e09d3ea..bb0a0dbd7f 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs @@ -4,7 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Edit { @@ -22,53 +22,63 @@ namespace osu.Game.Rulesets.Edit Bindable DistanceSpacingMultiplier { get; } /// - /// Retrieves the distance between two points within a timing point that are one beat length apart. + /// Returns the spatial distance between objects which are temporally one beat apart. + /// Depends on: + /// + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// Whether the 's slider velocity should be factored into the returned distance. - /// The distance between two points residing in the timing point that are one beat length apart. - float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true); + float GetBeatSnapDistance(IHasSliderVelocity? withVelocity = null); /// - /// Converts a duration to a distance without applying any snapping. + /// Converts a temporal duration into a spatial distance. + /// Does not perform any snapping. + /// Depends on: + /// + /// the provided, + /// a used to retrieve the beat length of the beatmap at that time, + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// The duration to convert. - /// A value that represents as a distance in the timing point. - float DurationToDistance(HitObject referenceObject, double duration); + float DurationToDistance(double duration, double timingReference, IHasSliderVelocity? withVelocity = null); /// - /// Converts a distance to a duration without applying any snapping. + /// Converts a spatial distance into a temporal duration. + /// Does not perform any snapping. + /// Depends on: + /// + /// the provided, + /// a used to retrieve the beat length of the beatmap at that time, + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// The distance to convert. - /// A value that represents as a duration in the timing point. - double DistanceToDuration(HitObject referenceObject, float distance); + double DistanceToDuration(float distance, double timingReference, IHasSliderVelocity? withVelocity = null); /// - /// Given a distance from the provided hit object, find the valid snapped duration. + /// Snaps a spatial distance to the beat, relative to . + /// Depends on: + /// + /// the provided, + /// a used to retrieve the beat length of the beatmap at that time, + /// the slider velocity taken from , + /// the beatmap's ,, + /// the current beat divisor. + /// + /// Note that the returned value does NOT depend on ; + /// consumers are expected to include that multiplier as they see fit. /// - /// An object to be used as a reference point for this operation. - /// The distance to convert. - /// A value that represents as a duration snapped to the closest beat of the timing point. - double FindSnappedDuration(HitObject referenceObject, float distance); - - /// - /// Given a distance from the provided hit object, find the valid snapped distance. - /// - /// An object to be used as a reference point for this operation. - /// The distance to convert. - /// Whether the distance measured should be from the start or the end of . - /// - /// A value that represents snapped to the closest beat of the timing point. - /// The distance will always be less than or equal to the provided . - /// - float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target); - } - - public enum DistanceSnapTarget - { - Start, - End, + float FindSnappedDistance(float distance, double snapReferenceTime, IHasSliderVelocity? withVelocity = null); } } diff --git a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs deleted file mode 100644 index 002a0aafe6..0000000000 --- a/osu.Game/Rulesets/Edit/IPositionSnapProvider.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.Allocation; -using osuTK; - -namespace osu.Game.Rulesets.Edit -{ - /// - /// A snap provider which given a proposed position for a hit object, potentially offers a more correct position and time value inferred from the context of the beatmap. - /// - [Cached] - public interface IPositionSnapProvider - { - /// - /// Given a position, find a valid time and position snap. - /// - /// The screen-space position to be snapped. - /// The type of snapping to apply. - /// The time and position post-snapping. - SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); - } -} diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 52b8a5c796..f2d501d1c4 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -7,6 +7,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Objects; +using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Edit @@ -75,18 +76,7 @@ namespace osu.Game.Rulesets.Edit PlacementActive = PlacementState.Finished; } - /// - /// Determines which objects to snap to for the snap result in . - /// - public virtual SnapType SnapType => SnapType.All; - - /// - /// Updates the time and position of this based on the provided snap information. - /// - /// The snap result information. - public virtual void UpdateTimeAndPosition(SnapResult result) - { - } + public abstract SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime); public bool OnPressed(KeyBindingPressEvent e) { diff --git a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs index 223b770b48..3671724042 100644 --- a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs @@ -56,7 +56,12 @@ namespace osu.Game.Rulesets.Edit Spacing = new Vector2(0, 5), Children = new[] { - new DrawableTernaryButton(new TernaryButton(showSpeedChanges, "Show speed changes", () => new SpriteIcon { Icon = FontAwesome.Solid.TachometerAlt })) + new DrawableTernaryButton + { + Current = showSpeedChanges, + Description = "Show speed changes", + CreateIcon = () => new SpriteIcon { Icon = FontAwesome.Solid.TachometerAlt }, + } } }, }); @@ -112,6 +117,23 @@ namespace osu.Game.Rulesets.Edit } } + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) + { + var scrollingPlayfield = PlayfieldAtScreenSpacePosition(screenSpacePosition) as ScrollingPlayfield; + if (scrollingPlayfield == null) + return new SnapResult(screenSpacePosition, null); + + double? targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); + + // apply beat snapping + targetTime = BeatSnapProvider.SnapTime(targetTime.Value); + + // convert back to screen space + screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); + + return new SnapResult(screenSpacePosition, targetTime, scrollingPlayfield); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Rulesets/Edit/SnapType.cs b/osu.Game/Rulesets/Edit/SnapType.cs deleted file mode 100644 index cf743f6ace..0000000000 --- a/osu.Game/Rulesets/Edit/SnapType.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; - -namespace osu.Game.Rulesets.Edit -{ - [Flags] - public enum SnapType - { - None = 0, - - /// - /// Snapping to visible nearby objects. - /// - NearbyObjects = 1 << 0, - - /// - /// Grids which are global to the playfield. - /// - GlobalGrids = 1 << 1, - - /// - /// Grids which are relative to other nearby hit objects. - /// - RelativeGrids = 1 << 2, - - AllGrids = RelativeGrids | GlobalGrids, - - All = NearbyObjects | GlobalGrids | RelativeGrids, - } -} diff --git a/osu.Game/Rulesets/IRulesetConfigCache.cs b/osu.Game/Rulesets/IRulesetConfigCache.cs index 3943a62e59..e14c634d3c 100644 --- a/osu.Game/Rulesets/IRulesetConfigCache.cs +++ b/osu.Game/Rulesets/IRulesetConfigCache.cs @@ -16,6 +16,6 @@ namespace osu.Game.Rulesets /// /// The to retrieve the for. /// The defined by , null if doesn't define one. - public IRulesetConfigManager? GetConfigFor(Ruleset ruleset); + IRulesetConfigManager? GetConfigFor(Ruleset ruleset); } } diff --git a/osu.Game/Rulesets/Judgements/JudgementResult.cs b/osu.Game/Rulesets/Judgements/JudgementResult.cs index 4b98df50d7..ab83ee62b0 100644 --- a/osu.Game/Rulesets/Judgements/JudgementResult.cs +++ b/osu.Game/Rulesets/Judgements/JudgementResult.cs @@ -74,6 +74,11 @@ namespace osu.Game.Rulesets.Judgements /// public int HighestComboAtJudgement { get; internal set; } + /// + /// The highest combo achieved after this occurred. + /// + public int HighestComboAfterJudgement { get; internal set; } + /// /// The health prior to this occurring. /// diff --git a/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs index d04d7636ec..6697a8d848 100644 --- a/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs +++ b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs @@ -31,12 +31,7 @@ namespace osu.Game.Rulesets.Mods protected sealed override Drawable CreateControl() => new SliderControl(sliderDisplayCurrent, CreateSlider); - protected virtual RoundedSliderBar CreateSlider(BindableNumber current) => new RoundedSliderBar - { - RelativeSizeAxes = Axes.X, - Current = current, - KeyboardStep = 0.1f, - }; + protected virtual RoundedSliderBar CreateSlider(BindableNumber current) => new RoundedSliderBar(); /// /// Guards against beatmap values displayed on slider bars being transferred to user override. @@ -111,7 +106,21 @@ namespace osu.Game.Rulesets.Mods { InternalChildren = new Drawable[] { - createSlider(currentNumber) + createSlider(currentNumber).With(slider => + { + slider.RelativeSizeAxes = Axes.X; + slider.Current = currentNumber; + slider.KeyboardStep = 0.1f; + // this looks redundant, but isn't because of the various games this component plays + // (`Current` is nullable and represents the underlying setting value, + // `currentNumber` is not nullable and represents what is getting displayed, + // therefore without this, double-clicking the slider would reset `currentNumber` to its bogus default of 0). + slider.ResetToDefault = () => + { + if (!Current.Disabled) + Current.SetDefault(); + }; + }) }; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 3a33d14835..08e64c4aa9 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods { @@ -42,7 +44,7 @@ namespace osu.Game.Rulesets.Mods IconUsage? Icon { get; } /// - /// Whether this mod is playable by an end user. + /// Whether this mod is playable by a real human 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; } @@ -53,6 +55,12 @@ namespace osu.Game.Rulesets.Mods /// bool ValidForMultiplayer { get; } + /// + /// Whether this mod is valid as a required mod when freestyle is enabled. + /// Should be true for mods that are guaranteed to be implemented across all rulesets. + /// + bool ValidForFreestyleAsRequiredMod { get; } + /// /// Whether this mod is valid as a free mod in multiplayer matches. /// Should be false for mods that affect the gameplay duration (e.g. and ). @@ -75,5 +83,33 @@ namespace osu.Game.Rulesets.Mods /// Create a fresh instance based on this mod. /// Mod CreateInstance() => (Mod)Activator.CreateInstance(GetType())!; + + /// + /// Whether any user adjustable setting attached to this mod has a non-default value. + /// + /// + /// This returns the instantaneous state of this mod. It may change over time. + /// For tracking changes on a dynamic display, make sure to setup a . + /// + bool HasNonDefaultSettings + { + get + { + bool hasAdjustments = false; + + foreach (var (_, property) in this.GetSettingsSourceProperties()) + { + var bindable = (IBindable)property.GetValue(this)!; + + if (!bindable.IsDefault) + { + hasAdjustments = true; + break; + } + } + + return hasAdjustments; + } + } } } diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 1b21216235..477372b97d 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Extensions; -using osu.Game.Rulesets.UI; using osu.Game.Utils; namespace osu.Game.Rulesets.Mods @@ -43,46 +42,29 @@ namespace osu.Game.Rulesets.Mods public abstract LocalisableString Description { get; } /// - /// The tooltip to display for this mod when used in a . - /// - /// - /// Differs from , as the value of attributes (AR, CS, etc) changeable via the mod - /// are displayed in the tooltip. - /// - [JsonIgnore] - public string IconTooltip - { - get - { - string description = SettingDescription; - - return string.IsNullOrEmpty(description) ? Name : $"{Name} ({description})"; - } - } - - /// - /// The description of editable settings of a mod to use in the . + /// The description of editable settings of a mod. /// /// /// Parentheses are added to the tooltip, surrounding the value of this property. If this property is string.Empty, /// the tooltip will not have parentheses. /// - public virtual string SettingDescription + public virtual IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get { - var tooltipTexts = new List(); - foreach ((SettingSourceAttribute attr, PropertyInfo property) in this.GetOrderedSettingsSourceProperties()) { var bindable = (IBindable)property.GetValue(this)!; + if (bindable.IsDefault) + continue; + string valueText; switch (bindable) { case Bindable b: - valueText = b.Value ? "on" : "off"; + valueText = b.Value ? "On" : "Off"; break; default: @@ -90,11 +72,8 @@ namespace osu.Game.Rulesets.Mods break; } - if (!bindable.IsDefault) - tooltipTexts.Add($"{attr.Label}: {valueText}"); + yield return (attr.Label, valueText); } - - return string.Join(", ", tooltipTexts.Where(s => !string.IsNullOrEmpty(s))); } } @@ -110,56 +89,17 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool HasImplementation => this is IApplicableMod; - /// - /// Whether this mod can be played by a real human user. - /// Non-user-playable mods are not viable for single-player score submission. - /// - /// - /// - /// is user-playable. - /// is not user-playable. - /// - /// [JsonIgnore] public virtual bool UserPlayable => true; - /// - /// Whether this mod can be specified as a "required" mod in a multiplayer context. - /// - /// - /// - /// is valid for multiplayer. - /// - /// is valid for multiplayer as long as it is a required mod, - /// as that ensures the same duration of gameplay for all users in the room. - /// - /// - /// is not valid for multiplayer, as it leads to varying - /// gameplay duration depending on how the users in the room play. - /// - /// is not valid for multiplayer. - /// - /// [JsonIgnore] public virtual bool ValidForMultiplayer => true; - /// - /// Whether this mod can be specified as a "free" or "allowed" mod in a multiplayer context. - /// - /// - /// - /// is valid for multiplayer as a free mod. - /// - /// is not valid for multiplayer as a free mod, - /// as it could to varying gameplay duration between users in the room depending on whether they picked it. - /// - /// is not valid for multiplayer as a free mod. - /// - /// + public virtual bool ValidForFreestyleAsRequiredMod => false; + [JsonIgnore] public virtual bool ValidForMultiplayerAsFreeMod => true; - /// [JsonIgnore] public virtual bool AlwaysValidForSubmission => false; @@ -169,9 +109,6 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool RequiresConfiguration => false; - /// - /// Whether scores with this mod active can give performance points. - /// [JsonIgnore] public virtual bool Ranked => false; diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index 9570cddb0a..f26a1bd477 100644 --- a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -2,11 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Globalization; +using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Localisation.HUD; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Scoring; @@ -23,6 +26,8 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Fail if your accuracy drops too low!"; + public override IconUsage? Icon => OsuIcon.ModAccuracyChallenge; + public override ModType Type => ModType.DifficultyIncrease; public override double ScoreMultiplier => 1.0; @@ -33,7 +38,20 @@ namespace osu.Game.Rulesets.Mods public override bool Ranked => true; - public override string SettingDescription => base.SettingDescription.Replace(MinimumAccuracy.ToString(), MinimumAccuracy.Value.ToString("##%", NumberFormatInfo.InvariantInfo)); + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!MinimumAccuracy.IsDefault) + yield return ("Minimum accuracy", $"{MinimumAccuracy.Value:##%}"); + + if (!AccuracyJudgeMode.IsDefault) + yield return ("Accuracy mode", AccuracyJudgeMode.Value.ToLocalisableString()); + + if (!Restart.IsDefault) + yield return ("Restart on fail", "On"); + } + } [SettingSource("Minimum accuracy", "Trigger a failure if your accuracy goes below this value.", SettingControlType = typeof(SettingsPercentageSlider))] public BindableNumber MinimumAccuracy { get; } = new BindableDouble diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs index 83a48599ca..63d2f7d7f3 100644 --- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -6,10 +6,12 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -27,6 +29,8 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Let track speed adapt to you."; + public override IconUsage? Icon => OsuIcon.ModAdaptiveSpeed; + public override ModType Type => ModType.Fun; public override double ScoreMultiplier => 0.5; diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index 302cdf69c0..01e01a0d9a 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Autoplay"; public override string Acronym => "AT"; - public override IconUsage? Icon => OsuIcon.ModAuto; + public override IconUsage? Icon => OsuIcon.ModAutoplay; public override ModType Type => ModType.Automation; public override LocalisableString Description => "Watch a perfect automated play through the song."; public override double ScoreMultiplier => 1; diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs index 67f9da37be..98a7999065 100644 --- a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -2,11 +2,14 @@ // 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.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osuTK; @@ -35,16 +38,27 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Barrel Roll"; public override string Acronym => "BR"; + public override IconUsage? Icon => OsuIcon.ModBarrelRoll; public override LocalisableString Description => "The whole playfield is on a wheel!"; public override double ScoreMultiplier => 1; - public override string SettingDescription => $"{SpinSpeed.Value:N2} rpm {Direction.Value.GetDescription().ToLowerInvariant()}"; + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!SpinSpeed.IsDefault) + yield return ("Roll speed", $"{SpinSpeed.Value:N2} rpm"); + if (!Direction.IsDefault) + yield return ("Direction", Direction.Value.GetDescription()); + } + } private PlayfieldAdjustmentContainer playfieldAdjustmentContainer = null!; public virtual void Update(Playfield playfield) { - playfieldAdjustmentContainer.Rotation = CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); + playfieldAdjustmentContainer.Rotation = + CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); } public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index b0f6ba9374..e8c6bd09c1 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 0.96; - public override IconUsage? Icon => FontAwesome.Solid.History; + public override IconUsage? Icon => OsuIcon.ModClassic; public override LocalisableString Description => "Feeling nostalgic?"; @@ -30,5 +31,7 @@ namespace osu.Game.Rulesets.Mods /// - Sliders always gives combo for slider end, even on miss (https://github.com/ppy/osu/issues/11769). /// public sealed override bool Ranked => false; + + public sealed override bool ValidForFreestyleAsRequiredMod => false; } } diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index 359f8a950c..98ecf0d46a 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Overlays.Settings; namespace osu.Game.Rulesets.Mods @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Daycore"; public override string Acronym => "DC"; - public override IconUsage? Icon => null; + public override IconUsage? Icon => OsuIcon.ModDaycore; public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Whoaaaaa..."; public override bool Ranked => UsesDefaultConfiguration; diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index f4c6be4f77..c6eaa75e9e 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; +using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { @@ -21,12 +23,14 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.Conversion; - public override IconUsage? Icon => FontAwesome.Solid.Hammer; + public override IconUsage? Icon => OsuIcon.ModDifficultyAdjust; public override double ScoreMultiplier => 0.5; public override bool RequiresConfiguration => true; + public override bool ValidForFreestyleAsRequiredMod => true; + public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock) }; protected const int FIRST_SETTING_ORDER = 1; @@ -44,7 +48,7 @@ namespace osu.Game.Rulesets.Mods }; [SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER, SettingControlType = typeof(DifficultyAdjustSettingsControl))] - public DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable + public virtual DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable { Precision = 0.1f, MinValue = 0, @@ -65,23 +69,50 @@ namespace osu.Game.Rulesets.Mods } } - public override string SettingDescription + public override string ExtendedIconInformation { get { - string drainRate = DrainRate.IsDefault ? string.Empty : $"HP {DrainRate.Value:N1}"; - string overallDifficulty = OverallDifficulty.IsDefault ? string.Empty : $"OD {OverallDifficulty.Value:N1}"; + if (!IsExactlyOneSettingChanged(OverallDifficulty, DrainRate)) + return string.Empty; - return string.Join(", ", new[] - { - drainRate, - overallDifficulty - }.Where(s => !string.IsNullOrEmpty(s))); + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); + if (!DrainRate.IsDefault) return format("HP", DrainRate); + + return string.Empty; + + string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; } } - public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) + protected bool IsExactlyOneSettingChanged(params DifficultyBindable[] difficultySettings) { + DifficultyBindable? changedSetting = null; + + foreach (var setting in difficultySettings) + { + if (setting.IsDefault) + continue; + + if (changedSetting != null) + return false; + + changedSetting = setting; + } + + return changedSetting != null; + } + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!DrainRate.IsDefault) + yield return ("HP drain", $"{DrainRate.Value:N1}"); + + if (!OverallDifficulty.IsDefault) + yield return ("Accuracy", $"{OverallDifficulty.Value:N1}"); + } } public void ApplyToDifficulty(BeatmapDifficulty difficulty) => ApplySettings(difficulty); diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index fd5120a767..e6aa715a37 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.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.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; @@ -31,6 +32,18 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public virtual BindableBool AdjustPitch { get; } = new BindableBool(); + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + foreach (var description in base.SettingDescription) + yield return description; + + if (!AdjustPitch.IsDefault) + yield return ("Adjust pitch", AdjustPitch.Value ? "On" : "Off"); + } + } + private readonly RateAdjustModHelper rateAdjustHelper; protected ModDoubleTime() diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index da43a6b294..0ee384c0f7 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -17,18 +17,15 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModHardRock), typeof(ModDifficultyAdjust) }; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyleAsRequiredMod => true; - public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty) - { - } + protected const float ADJUST_RATIO = 0.5f; public virtual void ApplyToDifficulty(BeatmapDifficulty difficulty) { - const float ratio = 0.5f; - difficulty.CircleSize *= ratio; - difficulty.ApproachRate *= ratio; - difficulty.DrainRate *= ratio; - difficulty.OverallDifficulty *= ratio; + difficulty.CircleSize *= ADJUST_RATIO; + difficulty.ApproachRate *= ADJUST_RATIO; + difficulty.DrainRate *= ADJUST_RATIO; } } } diff --git a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs index e101ac440e..1a2cb08a53 100644 --- a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs +++ b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs @@ -2,9 +2,11 @@ // 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.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; @@ -20,7 +22,15 @@ namespace osu.Game.Rulesets.Mods MaxValue = 10 }; - public override string SettingDescription => Retries.IsDefault ? string.Empty : $"{"lives".ToQuantity(Retries.Value)}"; + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!Retries.IsDefault) + yield return ("Extra lives", "lives".ToQuantity(Retries.Value)); + } + } + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAccuracyChallenge)).ToArray(); private int retries; diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 64c193d25f..884066d7ab 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Runtime.InteropServices; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,8 +13,8 @@ using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Shaders.Types; using osu.Framework.Graphics.Sprites; +using osu.Framework.Layout; using osu.Framework.Localisation; -using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.OpenGL.Vertices; @@ -85,7 +84,11 @@ namespace osu.Game.Rulesets.Mods flashlight.Colour = Color4.Black; flashlight.Combo.BindTo(Combo); - flashlight.GetPlayfieldScale = () => drawableRuleset.Playfield.Scale; + + var playfieldDrawInfoTracker = new PlayfieldDrawInfoTracker(); + + drawableRuleset.PlayfieldAdjustmentContainer.Add(playfieldDrawInfoTracker); + flashlight.PlayfieldDrawInfoTracker = playfieldDrawInfoTracker; drawableRuleset.Overlays.Add(new Container { @@ -111,7 +114,9 @@ namespace osu.Game.Rulesets.Mods public override bool RemoveCompletedTransforms => false; - internal Func? GetPlayfieldScale; + internal PlayfieldDrawInfoTracker PlayfieldDrawInfoTracker { get; set; } = null!; + + private DrawInfo playfieldDrawInfo => PlayfieldDrawInfoTracker.DrawInfo; private readonly float defaultFlashlightSize; private readonly float sizeMultiplier; @@ -146,6 +151,8 @@ namespace osu.Game.Rulesets.Mods isBreakTime.BindTo(player.IsBreakTime); isBreakTime.BindValueChanged(_ => UpdateFlashlightSize(GetSize()), true); } + + PlayfieldDrawInfoTracker.OnDrawInfoInvalidate += () => Invalidate(Invalidation.DrawNode); } protected abstract void UpdateFlashlightSize(float size); @@ -156,15 +163,6 @@ namespace osu.Game.Rulesets.Mods { float size = defaultFlashlightSize * sizeMultiplier; - if (GetPlayfieldScale != null) - { - Vector2 playfieldScale = GetPlayfieldScale(); - - Debug.Assert(Precision.AlmostEquals(Math.Abs(playfieldScale.X), Math.Abs(playfieldScale.Y)), - @"Playfield has non-proportional scaling. Flashlight implementations should be revisited with regard to balance."); - size *= Math.Abs(playfieldScale.X); - } - if (isBreakTime.Value) size *= 2.5f; else if (comboBasedSize) @@ -265,7 +263,11 @@ namespace osu.Game.Rulesets.Mods shader = Source.shader; screenSpaceDrawQuad = Source.ScreenSpaceDrawQuad; flashlightPosition = Vector2Extensions.Transform(Source.FlashlightPosition, DrawInfo.Matrix); - flashlightSize = Source.FlashlightSize * DrawInfo.Matrix.ExtractScale().Xy; + + // scale the flashlight based on the playfield to match gameplay components scale. + Vector2 drawInfoScale = Source.playfieldDrawInfo.Matrix.ExtractScale().Xy; + flashlightSize = Source.FlashlightSize * drawInfoScale; + flashlightDim = Source.FlashlightDim; flashlightSmoothness = Source.flashlightSmoothness; } @@ -321,5 +323,33 @@ namespace osu.Game.Rulesets.Mods } } } + + /// + /// The purpose of this component is to track any changes to Playfield.Parent.DrawInfo + /// (by being added to the content of ). + /// All in order for the flashlight to invalidate its draw node and read any changes in the playfield's scaling. + /// + internal partial class PlayfieldDrawInfoTracker : Component + { + private readonly LayoutValue drawInfoLayout = new LayoutValue(Invalidation.DrawInfo); + + public Action? OnDrawInfoInvalidate; + + public PlayfieldDrawInfoTracker() + { + AddLayout(drawInfoLayout); + } + + protected override void Update() + { + base.Update(); + + if (!drawInfoLayout.IsValid) + { + OnDrawInfoInvalidate?.Invoke(); + drawInfoLayout.Validate(); + } + } + } } } diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index efdf0d6358..c91c8b2718 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.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.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Half Time"; public override string Acronym => "HT"; - public override IconUsage? Icon => OsuIcon.ModHalftime; + public override IconUsage? Icon => OsuIcon.ModHalfTime; public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Less zoom..."; public override bool Ranked => SpeedChange.IsDefault; @@ -31,6 +32,18 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public virtual BindableBool AdjustPitch { get; } = new BindableBool(); + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + foreach (var description in base.SettingDescription) + yield return description; + + if (!AdjustPitch.IsDefault) + yield return ("Adjust pitch", AdjustPitch.Value ? "On" : "Off"); + } + } + private readonly RateAdjustModHelper rateAdjustHelper; protected ModHalfTime() diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 1e99891b99..713bfe0623 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -18,17 +18,13 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyleAsRequiredMod => true; protected const float ADJUST_RATIO = 1.4f; - public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) - { - } - public virtual void ApplyToDifficulty(BeatmapDifficulty difficulty) { difficulty.DrainRate = Math.Min(difficulty.DrainRate * ADJUST_RATIO, 10.0f); - difficulty.OverallDifficulty = Math.Min(difficulty.OverallDifficulty * ADJUST_RATIO, 10.0f); } } } diff --git a/osu.Game/Rulesets/Mods/ModMirror.cs b/osu.Game/Rulesets/Mods/ModMirror.cs index 3c4b7d0c60..c2e21c6770 100644 --- a/osu.Game/Rulesets/Mods/ModMirror.cs +++ b/osu.Game/Rulesets/Mods/ModMirror.cs @@ -1,12 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; + namespace osu.Game.Rulesets.Mods { public abstract class ModMirror : Mod { public override string Name => "Mirror"; public override string Acronym => "MR"; + public override IconUsage? Icon => OsuIcon.ModMirror; 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 index 7aefefc58d..933e7f4093 100644 --- a/osu.Game/Rulesets/Mods/ModMuted.cs +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Objects; @@ -21,11 +22,12 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Muted"; public override string Acronym => "MU"; - public override IconUsage? Icon => FontAwesome.Solid.VolumeMute; + public override IconUsage? Icon => OsuIcon.ModMuted; public override LocalisableString Description => "Can you still feel the rhythm without music?"; public override ModType Type => ModType.Fun; public override double ScoreMultiplier => 1; public override bool Ranked => true; + public override bool ValidForFreestyleAsRequiredMod => true; } public abstract class ModMuted : ModMuted, IApplicableToDrawableRuleset, IApplicableToTrack, IApplicableToScoreProcessor diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 1aaef8eac4..121524e594 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -21,6 +21,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModFailCondition), typeof(ModCinema) }; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyleAsRequiredMod => true; private readonly Bindable showHealthBar = new Bindable(); diff --git a/osu.Game/Rulesets/Mods/ModNoMod.cs b/osu.Game/Rulesets/Mods/ModNoMod.cs index 5dd4b317e7..0f55ab126f 100644 --- a/osu.Game/Rulesets/Mods/ModNoMod.cs +++ b/osu.Game/Rulesets/Mods/ModNoMod.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "NM"; public override LocalisableString Description => "No mods applied."; public override double ScoreMultiplier => 1; - public override IconUsage? Icon => FontAwesome.Solid.Ban; + public override IconUsage? Icon => OsuIcon.ModNoMod; public override ModType Type => ModType.System; } } diff --git a/osu.Game/Rulesets/Mods/ModNoScope.cs b/osu.Game/Rulesets/Mods/ModNoScope.cs index dd1bd9a719..d0c9da669b 100644 --- a/osu.Game/Rulesets/Mods/ModNoScope.cs +++ b/osu.Game/Rulesets/Mods/ModNoScope.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Scoring; @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "No Scope"; public override string Acronym => "NS"; public override ModType Type => ModType.Fun; - public override IconUsage? Icon => FontAwesome.Solid.EyeSlash; + public override IconUsage? Icon => OsuIcon.ModNoScope; public override double ScoreMultiplier => 1; public override bool Ranked => true; diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 5bedf443da..e7957ac4c5 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 1; public override LocalisableString Description => "SS or quit."; public override bool Ranked => true; + public override bool ValidForFreestyleAsRequiredMod => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModSuddenDeath), typeof(ModAccuracyChallenge) }).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs index 178b9fb619..684caa2a3f 100644 --- a/osu.Game/Rulesets/Mods/ModRandom.cs +++ b/osu.Game/Rulesets/Mods/ModRandom.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Random"; public override string Acronym => "RD"; public override ModType Type => ModType.Conversion; - public override IconUsage? Icon => OsuIcon.Dice; + public override IconUsage? Icon => OsuIcon.ModRandom; public override double ScoreMultiplier => 1; [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index e5af758b4f..49bdd93bc6 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -2,13 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { public abstract class ModRateAdjust : Mod, IApplicableToRate { + public sealed override bool ValidForFreestyleAsRequiredMod => true; public sealed override bool ValidForMultiplayerAsFreeMod => false; public abstract BindableNumber SpeedChange { get; } @@ -24,8 +27,15 @@ namespace osu.Game.Rulesets.Mods public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) }; - public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!SpeedChange.IsDefault) + yield return ("Speed change", FormattableString.Invariant($@"{SpeedChange.Value:N2}x")); + } + } - public override string ExtendedIconInformation => SettingDescription; + public override string ExtendedIconInformation => SpeedChange.IsDefault ? string.Empty : FormattableString.Invariant($"{SpeedChange.Value:N2}x"); } } diff --git a/osu.Game/Rulesets/Mods/ModScoreV2.cs b/osu.Game/Rulesets/Mods/ModScoreV2.cs index 6a77cafa30..dce6e146df 100644 --- a/osu.Game/Rulesets/Mods/ModScoreV2.cs +++ b/osu.Game/Rulesets/Mods/ModScoreV2.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { @@ -9,10 +11,11 @@ namespace osu.Game.Rulesets.Mods /// This mod is used strictly to mark osu!stable scores set with the "Score V2" mod active. /// It should not be used in any real capacity going forward. /// - public sealed class ModScoreV2 : Mod + public class ModScoreV2 : Mod { public override string Name => "Score V2"; public override string Acronym => @"SV2"; + public override IconUsage? Icon => OsuIcon.ModScoreV2; public override ModType Type => ModType.System; public override LocalisableString Description => "Score set on earlier osu! versions with the V2 scoring algorithm active."; public override double ScoreMultiplier => 1; diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index d07ff6ce87..f82033938a 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override bool Ranked => true; + public override bool ValidForFreestyleAsRequiredMod => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModSynesthesia.cs b/osu.Game/Rulesets/Mods/ModSynesthesia.cs index 9084127f33..31ff7ca3fe 100644 --- a/osu.Game/Rulesets/Mods/ModSynesthesia.cs +++ b/osu.Game/Rulesets/Mods/ModSynesthesia.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { @@ -14,6 +16,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "SY"; public override LocalisableString Description => "Colours hit objects based on the rhythm."; public override double ScoreMultiplier => 0.8; + public override IconUsage? Icon => OsuIcon.ModSynesthesia; public override ModType Type => ModType.Fun; } } diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index 36e4522771..049b8f9b7f 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Overlays.Settings; @@ -30,11 +32,22 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public abstract BindableBool AdjustPitch { get; } + public sealed override bool ValidForFreestyleAsRequiredMod => true; public sealed override bool ValidForMultiplayerAsFreeMod => false; public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) }; - public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x"; + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!InitialRate.IsDefault || !FinalRate.IsDefault) + yield return ("Speed change", $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x"); + + if (!AdjustPitch.IsDefault) + yield return ("Adjust pitch", AdjustPitch.Value ? "On" : "Off"); + } + } private double finalRateTime; private double beginRampTime; diff --git a/osu.Game/Rulesets/Mods/ModTouchDevice.cs b/osu.Game/Rulesets/Mods/ModTouchDevice.cs index e91a398700..f5e6fc03bf 100644 --- a/osu.Game/Rulesets/Mods/ModTouchDevice.cs +++ b/osu.Game/Rulesets/Mods/ModTouchDevice.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Mods { public sealed override string Name => "Touch Device"; public sealed override string Acronym => "TD"; - public sealed override IconUsage? Icon => OsuIcon.PlayStyleTouch; + public sealed override IconUsage? Icon => OsuIcon.ModTouchDevice; public sealed override LocalisableString Description => "Automatically applied to plays on devices with a touchscreen."; public sealed override double ScoreMultiplier => 1; public sealed override ModType Type => ModType.System; diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 35a673093b..cad16ab3bb 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Wind Down"; public override string Acronym => "WD"; public override LocalisableString Description => "Sloooow doooown..."; - public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleDown; + public override IconUsage? Icon => OsuIcon.ModWindDown; public override BindableNumber InitialRate { get; } = new BindableDouble(1) { diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index bbc8382055..42555137b5 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Wind Up"; public override string Acronym => "WU"; public override LocalisableString Description => "Can you keep up?"; - public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleUp; + public override IconUsage? Icon => OsuIcon.ModWindUp; public override BindableNumber InitialRate { get; } = new BindableDouble(1) { diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 1f735576bc..799556acdf 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; -using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using JetBrains.Annotations; @@ -16,7 +15,6 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Lists; -using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; @@ -63,6 +61,8 @@ namespace osu.Game.Rulesets.Objects.Drawables protected PausableSkinnableSound Samples { get; private set; } + private bool samplesLoaded; + public virtual IEnumerable GetSamples() => HitObject.Samples; private readonly List nestedHitObjects = new List(); @@ -227,6 +227,12 @@ namespace osu.Game.Rulesets.Objects.Drawables comboColourBrightness.BindValueChanged(_ => UpdateComboColour()); + samplesBindable.BindCollectionChanged((_, _) => + { + if (samplesLoaded) + LoadSamples(); + }); + // Apply transforms updateStateFromResult(); } @@ -293,8 +299,6 @@ namespace osu.Game.Rulesets.Objects.Drawables } samplesBindable.BindTo(HitObject.SamplesBindable); - samplesBindable.BindCollectionChanged(onSamplesChanged, true); - HitObject.DefaultsApplied += onDefaultsApplied; OnApply(); @@ -335,11 +339,8 @@ namespace osu.Game.Rulesets.Objects.Drawables samplesBindable.UnbindFrom(HitObject.SamplesBindable); - // When a new hitobject is applied, the samples will be cleared before re-populating. - // In order to stop this needless update, the event is unbound and re-bound as late as possible in Apply(). - samplesBindable.CollectionChanged -= onSamplesChanged; - // Release the samples for other hitobjects to use. + samplesLoaded = false; Samples?.ClearSamples(); foreach (var obj in nestedHitObjects) @@ -396,8 +397,6 @@ namespace osu.Game.Rulesets.Objects.Drawables Samples.Samples = samples.Cast().ToArray(); } - private void onSamplesChanged(object sender, NotifyCollectionChangedEventArgs e) => LoadSamples(); - private void onNewResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnNewResult?.Invoke(drawableHitObject, result); private void onRevertResult() @@ -631,6 +630,33 @@ namespace osu.Game.Rulesets.Objects.Drawables #endregion + protected override void Update() + { + // We use a flag here to load samples only when they are required to be played. + // Why in Update and not PlaySamples? Because some hit object implementations may expect LoadSamples to be called to load custom samples + // (slider slide sound as an example). + // + // This is best effort optimisation (over previous method of loading and de-pooling in `OnApply`) due to requiring knowledge of + // hitobjects' metadata. For cases like sliders with many repeats, there can be a sudden request to de-pool (ie slider with many repeats) + // hundreds of samples, causing a gameplay stutter. + // + // Note that we already have optimisations in OsuPlayfield for this but it applies to DrawableHitObjects and not samples. + // + // This is definitely not the end of optimisation of sample loading, but the structure of gameplay samples is going to take some + // time to dismantle and optimise. Optimally: + // + // - we would want to remove as much of the drawable overheads from samples as possible (currently two drawables per sample worst case) + // - pool the rawest representation of samples possible (if required at that point). + // - infer metadata at beatmap load to asynchronously preload the samples (into memory / bass). + if (!samplesLoaded) + { + samplesLoaded = true; + LoadSamples(); + } + + base.Update(); + } + public override bool UpdateSubTreeMasking() => false; protected override void UpdateAfterChildren() @@ -640,14 +666,6 @@ namespace osu.Game.Rulesets.Objects.Drawables UpdateResult(false); } - /// - /// Schedules an to this . - /// - /// - /// Only provided temporarily until hitobject pooling is implemented. - /// - protected internal new ScheduledDelegate Schedule(Action action) => base.Schedule(action); - /// /// An offset prior to the start time of at which this may begin displaying contents. /// By default, s are assumed to display their contents within 10 seconds prior to the start time of . @@ -689,9 +707,6 @@ namespace osu.Game.Rulesets.Objects.Drawables protected void ApplyResult(HitResult type) => ApplyResult(static (result, state) => result.Type = state, type); - [Obsolete("Use overload with state, preferrably with static delegates to avoid allocation overhead.")] // Can be removed 2024-07-26 - protected void ApplyResult(Action application) => ApplyResult((r, _) => application(r), this); - protected void ApplyResult(Action application) => ApplyResult(application, this); /// @@ -766,7 +781,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// Creates the that represents the scoring result for this . /// /// The that provides the scoring information. - protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(HitObject, judgement); + protected internal virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(HitObject, judgement); private void ensureEntryHasResult() { diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 9f980769e2..61c6c9f46f 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -104,6 +104,8 @@ namespace osu.Game.Rulesets.Objects /// The cancellation token. public void ApplyDefaults(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + ApplyDefaultsToSelf(controlPointInfo, difficulty); nestedHitObjects.Clear(); @@ -114,6 +116,8 @@ namespace osu.Game.Rulesets.Objects { foreach (HitObject hitObject in nestedHitObjects) { + cancellationToken.ThrowIfCancellationRequested(); + if (hitObject is IHasComboInformation n) { n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); @@ -188,7 +192,7 @@ namespace osu.Game.Rulesets.Objects /// /// [NotNull] - protected virtual HitWindows CreateHitWindows() => new HitWindows(); + protected virtual HitWindows CreateHitWindows() => new DefaultHitWindows(); /// /// The maximum offset from the end time of at which this can be judged. diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs index 28683583ee..091b0a1e6f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs @@ -21,13 +21,21 @@ namespace osu.Game.Rulesets.Objects.Legacy public int ComboOffset { get; set; } - public float X => Position.X; + public float X + { + get => Position.X; + set => Position = new Vector2(value, Position.Y); + } - public float Y => Position.Y; + public float Y + { + get => Position.Y; + set => Position = new Vector2(Position.X, value); + } public Vector2 Position { get; set; } - public LegacyHitObjectType LegacyType { get; set; } + public LegacyHitObjectType LegacyType { get; set; } = LegacyHitObjectType.Circle; public override Judgement CreateJudgement() => new IgnoreJudgement(); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 0162c8017b..0a6ef82b77 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -335,11 +335,14 @@ namespace osu.Game.Rulesets.Objects.Legacy ArrayPool<(PathType, int)>.Shared.Return(segmentsBuffer); } - static Vector2 readPoint(string value, Vector2 startPos) + Vector2 readPoint(string value, Vector2 startPos) { 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; + Vector2 pos = formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION + ? new Vector2(Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)) + : new Vector2((int)Parsing.ParseFloat(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)); + pos -= startPos; return pos; } } @@ -473,12 +476,30 @@ namespace osu.Game.Rulesets.Objects.Legacy private ConvertHitObject createSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, IList> nodeSamples) { + var path = new SliderPath(controlPoints, length); + + // there are known instances of beatmaps (https://osu.ppy.sh/beatmapsets/594828#osu/1258033) which contain zero-length sliders with non-zero numbers of repeats. + // this was exploiting a bug in stable in which the slider repeats would be generated as objects but never actually judged as a hit *or* miss during gameplay, + // therefore increasing the theoretical possible max combo to be gained from a slider while in practice never giving that extra combo. + // due to lazer ensuring that an object has its nested part fully judged, this would result in broken behaviours + // (either the zero-length slider giving hundreds of combo for nothing if the repeats are judged as hit, or insta-failing the player due to HP if judged as miss). + // to remedy this in a way that seems least damaging, detect this situation via a heuristic and reset the number of repeats to zero. + // this technically *does not* match stable beatmap parsing or conversion, *does not* match in-gameplay behaviour of such broken sliders, + // and *will* fail conversion mapping tests, but again, this is supposed to be a least-worst measure to prevent exploits. + // it is also applied centrally to all rulesets rather than in specific ruleset converters because this failure scenario + // translates across rulesets (osu! and catch are both affected). + if (Precision.AlmostEquals(path.Distance, 0)) + { + repeatCount = 0; + nodeSamples = [nodeSamples[0], nodeSamples[^1]]; + } + return lastObject = new ConvertSlider { Position = position, NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, ComboOffset = newCombo ? comboOffset : 0, - Path = new SliderPath(controlPoints, length), + Path = path, NodeSamples = nodeSamples, RepeatCount = repeatCount }; @@ -529,7 +550,6 @@ namespace osu.Game.Rulesets.Objects.Legacy } else { - // Todo: This should set the normal SampleInfo if the specified sample file isn't found, but that's a pretty edge-case scenario soundTypes.Add(new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume)); } @@ -587,7 +607,16 @@ namespace osu.Game.Rulesets.Objects.Legacy public class LegacyHitSampleInfo : HitSampleInfo, IEquatable { - public readonly int CustomSampleBank; + public int CustomSampleBank + { + get + { + if (Suffix != null) + return int.Parse(Suffix); + + return UseBeatmapSamples ? 1 : 0; + } + } /// /// Whether this hit sample is layered. @@ -605,16 +634,33 @@ namespace osu.Game.Rulesets.Objects.Legacy public bool BankSpecified; public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, bool editorAutoBank = false, int customSampleBank = 0, bool isLayered = false) - : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume, editorAutoBank) + : base( + name, + bank ?? SampleControlPoint.DEFAULT_BANK, + suffix: customSampleBank >= 2 ? customSampleBank.ToString() : null, + volume, + editorAutoBank, + useBeatmapSamples: customSampleBank >= 1) { - CustomSampleBank = customSampleBank; BankSpecified = !string.IsNullOrEmpty(bank); IsLayered = isLayered; } public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, - Optional newEditorAutoBank = default) - => With(newName, newBank, newVolume, newEditorAutoBank); + Optional newEditorAutoBank = default, Optional newUseBeatmapSamples = default) + { + string? suffix = newSuffix.GetOr(Suffix); + bool useBeatmapSamples = newUseBeatmapSamples.GetOr(UseBeatmapSamples); + int newCustomSampleBank = 0; + + if (suffix != null) + _ = int.TryParse(suffix, out newCustomSampleBank); + + if (newCustomSampleBank == 0 && useBeatmapSamples) + newCustomSampleBank = 1; + + return With(newName, newBank, newVolume, newEditorAutoBank, newCustomSampleBank); + } public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) @@ -633,14 +679,13 @@ namespace osu.Game.Rulesets.Objects.Legacy public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank, IsLayered); } - private class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable + public class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable { public readonly string Filename; public FileHitSampleInfo(string filename, int volume) // Force CSS=1 to make sure that the LegacyBeatmapSkin does not fall back to the user skin. - // Note that this does not change the lookup names, as they are overridden locally. - : base(string.Empty, customSampleBank: 1, volume: volume) + : base(HIT_NORMAL, SampleControlPoint.DEFAULT_BANK, customSampleBank: 1, volume: volume) { Filename = filename; } @@ -649,7 +694,7 @@ namespace osu.Game.Rulesets.Objects.Legacy { Filename, Path.ChangeExtension(Filename, null) - }; + }.Concat(base.LookupNames); public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs index d74224892b..939e4a495f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy @@ -16,5 +17,10 @@ namespace osu.Game.Rulesets.Objects.Legacy public double Duration { get; set; } public double EndTime => StartTime + Duration; + + public ConvertHold() + { + LegacyType = LegacyHitObjectType.Hold; + } } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index fee68f2f11..dbbe142944 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -8,6 +8,7 @@ using osu.Framework.Bindables; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; namespace osu.Game.Rulesets.Objects.Legacy { @@ -56,6 +57,11 @@ namespace osu.Game.Rulesets.Objects.Legacy public bool GenerateTicks { get; set; } = true; + public ConvertSlider() + { + LegacyType = LegacyHitObjectType.Slider; + } + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs index 59551cd37a..c2b4a9e16b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy @@ -16,5 +17,10 @@ namespace osu.Game.Rulesets.Objects.Legacy public double Duration { get; set; } public double EndTime => StartTime + Duration; + + public ConvertSpinner() + { + LegacyType = LegacyHitObjectType.Spinner; + } } } diff --git a/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs index efc10f26e1..e01df1428c 100644 --- a/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs +++ b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs @@ -160,8 +160,8 @@ namespace osu.Game.Rulesets.Objects.Pooling if (!IsPresent) return false; - bool aliveChanged = base.CheckChildrenLife(); - aliveChanged |= lifetimeManager.Update(Time.Current - PastLifetimeExtension, Time.Current + FutureLifetimeExtension); + bool aliveChanged = lifetimeManager.Update(Time.Current - PastLifetimeExtension, Time.Current + FutureLifetimeExtension); + aliveChanged |= base.CheckChildrenLife(); return aliveChanged; } } diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index 9b8375f208..e5e15042ff 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -44,13 +44,15 @@ namespace osu.Game.Rulesets.Objects PathProgress = 0, }; - if (tickDistance != 0) + for (int span = 0; span < spanCount; span++) { - for (int span = 0; span < spanCount; span++) - { - double spanStartTime = startTime + span * spanDuration; - bool reversed = span % 2 == 1; + cancellationToken.ThrowIfCancellationRequested(); + double spanStartTime = startTime + span * spanDuration; + bool reversed = span % 2 == 1; + + if (tickDistance != 0) + { var ticks = generateTicks(span, spanStartTime, spanDuration, reversed, length, tickDistance, minDistanceFromEnd, cancellationToken); if (reversed) @@ -61,18 +63,18 @@ namespace osu.Game.Rulesets.Objects foreach (var e in ticks) yield return e; + } - if (span < spanCount - 1) + if (span < spanCount - 1) + { + yield return new SliderEventDescriptor { - yield return new SliderEventDescriptor - { - Type = SliderEventType.Repeat, - SpanIndex = span, - SpanStartTime = startTime + span * spanDuration, - Time = spanStartTime + spanDuration, - PathProgress = (span + 1) % 2, - }; - } + Type = SliderEventType.Repeat, + SpanIndex = span, + SpanStartTime = startTime + span * spanDuration, + Time = spanStartTime + spanDuration, + PathProgress = (span + 1) % 2, + }; } } diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index 5550815370..eb591ec530 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -31,7 +31,10 @@ namespace osu.Game.Rulesets.Objects /// public readonly Bindable ExpectedDistance = new Bindable(); - public bool HasValidLength => Precision.DefinitelyBigger(Distance, 0); + /// + /// Should be used to check whether placement can continue after a user editor operation. + /// + public bool HasValidLengthForPlacement => Precision.DefinitelyBigger(Distance, 0, 1); /// /// The control points of the path. diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index a631274f74..4ce8166421 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -15,9 +15,9 @@ namespace osu.Game.Rulesets.Objects /// Snaps the provided 's duration using the . /// public static void SnapTo(this THitObject hitObject, IDistanceSnapProvider? snapProvider) - where THitObject : HitObject, IHasPath + where THitObject : HitObject, IHasPath, IHasSliderVelocity { - hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(hitObject, (float)hitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? hitObject.Path.CalculatedDistance; + hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance((float)hitObject.Path.CalculatedDistance, hitObject.StartTime, hitObject) ?? hitObject.Path.CalculatedDistance; } /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 3aa68197ec..a9fa918ea0 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -50,6 +50,9 @@ namespace osu.Game.Rulesets.Objects.Types /// new bool NewCombo { get; set; } + /// + new int ComboOffset { get; set; } + /// /// Bindable exposure of . /// @@ -84,19 +87,23 @@ namespace osu.Game.Rulesets.Objects.Types /// The previous hitobject, or null if this is the first object in the beatmap. void UpdateComboInformation(IHasComboInformation? lastObj) { - ComboIndex = lastObj?.ComboIndex ?? 0; - ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + int index = lastObj?.ComboIndex ?? 0; + int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; if (NewCombo || lastObj == null) { - IndexInCurrentCombo = 0; - ComboIndex++; - ComboIndexWithOffsets += ComboOffset + 1; + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; if (lastObj != null) lastObj.LastInCombo = true; } + + ComboIndex = index; + ComboIndexWithOffsets = indexWithOffsets; + IndexInCurrentCombo = inCurrentCombo; } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasGenerateTicks.cs b/osu.Game/Rulesets/Objects/Types/IHasGenerateTicks.cs index 3ac8b8a086..970753ac31 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasGenerateTicks.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasGenerateTicks.cs @@ -12,6 +12,6 @@ namespace osu.Game.Rulesets.Objects.Types /// Whether or not slider ticks should be generated by this object. /// This exists for backwards compatibility with maps that abuse NaN slider velocity behavior on osu!stable (e.g. /b/2628991). /// - public bool GenerateTicks { get; set; } + bool GenerateTicks { get; set; } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasPosition.cs index 8948fe59a9..e9b3cc46eb 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasPosition.cs @@ -13,6 +13,6 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The starting position of the HitObject. /// - Vector2 Position { get; } + Vector2 Position { get; set; } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs index 7e55b21050..18f1f996e3 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs @@ -11,6 +11,6 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The starting X-position of this HitObject. /// - float X { get; } + float X { get; set; } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs index d2561b10a7..dcaeaf594a 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs @@ -11,6 +11,6 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The starting Y-position of this HitObject. /// - float Y { get; } + float Y { get; set; } } } diff --git a/osu.Game/Rulesets/Replays/ReplayFrame.cs b/osu.Game/Rulesets/Replays/ReplayFrame.cs index 433be6e4b7..269de228b1 100644 --- a/osu.Game/Rulesets/Replays/ReplayFrame.cs +++ b/osu.Game/Rulesets/Replays/ReplayFrame.cs @@ -30,5 +30,10 @@ namespace osu.Game.Rulesets.Replays { Time = time; } + + /// + /// Whether this frame is equivalent to with respect to replay recording. + /// + public virtual bool IsEquivalentTo(ReplayFrame other) => Time == other.Time; } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index bd1f273b49..0dbe6e8845 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -17,6 +17,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; using osu.Game.Extensions; +using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; @@ -380,15 +381,39 @@ namespace osu.Game.Rulesets public virtual LocalisableString GetDisplayNameForHitResult(HitResult result) => result.GetLocalisableDescription(); /// - /// Applies changes to difficulty attributes for presenting to a user a rough estimate of how rate adjust mods affect difficulty. + /// Applies changes to difficulty attributes for presenting to a user a rough estimate of how mods affect difficulty. /// Importantly, this should NOT BE USED FOR ANY CALCULATIONS. /// /// It is also not always correct, and arguably is never correct depending on your frame of mind. /// - /// >The that will be adjusted. - /// The rate adjustment multiplier from mods. For example 1.5 for DT. + /// The for which to display the adjusted difficulty. + /// The active mods. /// The adjusted difficulty attributes. - public virtual BeatmapDifficulty GetRateAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, double rate) => new BeatmapDifficulty(difficulty); + public virtual BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) + { + BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(beatmapInfo.Difficulty); + + foreach (var mod in mods.OfType()) + mod.ApplyToDifficulty(adjustedDifficulty); + + return adjustedDifficulty; + } + + /// + /// Returns a list of s to be displayed wherever it is wanted to display a given beatmap's difficulty information. + /// The returned data includes both material changes to difficulty from mods, + /// as well as "effective" adjustments coming from . + /// + public virtual IEnumerable GetBeatmapAttributesForDisplay(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) + { + var originalDifficulty = beatmapInfo.Difficulty; + var adjustedDifficulty = GetAdjustedDisplayDifficulty(beatmapInfo, mods); + + yield return new RulesetBeatmapAttribute(SongSelectStrings.CircleSize, @"CS", originalDifficulty.CircleSize, adjustedDifficulty.CircleSize, 10); + yield return new RulesetBeatmapAttribute(SongSelectStrings.ApproachRate, @"AR", originalDifficulty.ApproachRate, adjustedDifficulty.ApproachRate, 10); + yield return new RulesetBeatmapAttribute(SongSelectStrings.Accuracy, @"OD", originalDifficulty.OverallDifficulty, adjustedDifficulty.OverallDifficulty, 10); + yield return new RulesetBeatmapAttribute(SongSelectStrings.HPDrain, @"HP", originalDifficulty.DrainRate, adjustedDifficulty.DrainRate, 10); + } /// /// Creates ruleset-specific beatmap filter criteria to be used on the song select screen. diff --git a/osu.Game/Rulesets/Scoring/DefaultHitWindows.cs b/osu.Game/Rulesets/Scoring/DefaultHitWindows.cs new file mode 100644 index 0000000000..3048233335 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/DefaultHitWindows.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// An example implementation of . + /// Not meaningfully used, provided mostly as a reference to ruleset implementors. + /// + public class DefaultHitWindows : HitWindows + { + private static readonly DifficultyRange perfect_window_range = new DifficultyRange(22.4D, 19.4D, 13.9D); + private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34); + private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67); + private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97); + private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121); + private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158); + + private double perfect; + private double great; + private double good; + private double ok; + private double meh; + private double miss; + + public override void SetDifficulty(double difficulty) + { + perfect = IBeatmapDifficultyInfo.DifficultyRange(difficulty, perfect_window_range); + great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, great_window_range); + good = IBeatmapDifficultyInfo.DifficultyRange(difficulty, good_window_range); + ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, ok_window_range); + meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, meh_window_range); + miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, miss_window_range); + } + + public override double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + return perfect; + + case HitResult.Great: + return great; + + case HitResult.Good: + return good; + + case HitResult.Ok: + return ok; + + case HitResult.Meh: + return meh; + + case HitResult.Miss: + return miss; + + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } + } +} diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index 2799cd4b36..d61e41f867 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -59,9 +59,12 @@ namespace osu.Game.Rulesets.Scoring protected override void RevertResultInternal(JudgementResult result) { - Health.Value = result.HealthAtJudgement; + HasFailed = result.FailedAtJudgement; - // Todo: Revert HasFailed state with proper player support + if (HasFailed) + return; + + Health.Value = result.HealthAtJudgement; } /// diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 269342460f..01d800a351 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -28,11 +28,12 @@ namespace osu.Game.Rulesets.Scoring result ??= new UnstableRateCalculationResult(); // Handle rewinding in the simplest way possible. - if (hitEvents.Count < result.EventCount + 1) + if (hitEvents.Count < result.LastProcessedIndex + 1) result = new UnstableRateCalculationResult(); - for (int i = result.EventCount; i < hitEvents.Count; i++) + for (int i = result.LastProcessedIndex + 1; i < hitEvents.Count; i++) { + result.LastProcessedIndex = i; HitEvent e = hitEvents[i]; if (!AffectsUnstableRate(e)) @@ -70,6 +71,26 @@ namespace osu.Game.Rulesets.Scoring return timeOffsets.Average(); } + /// + /// Calculates the median hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average. + /// + /// + /// A non-null value if unstable rate could be calculated, + /// and if unstable rate cannot be calculated due to being empty. + /// + public static double? CalculateMedianHitError(this IEnumerable hitEvents) + { + double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).OrderBy(x => x).ToArray(); + + if (timeOffsets.Length == 0) + return null; + + int center = timeOffsets.Length / 2; + + // Use average of the 2 central values if length is even + return timeOffsets.Length % 2 == 0 ? (timeOffsets[center - 1] + timeOffsets[center]) / 2 : timeOffsets[center]; + } + public static bool AffectsUnstableRate(HitEvent e) => AffectsUnstableRate(e.HitObject, e.Result); public static bool AffectsUnstableRate(HitObject hitObject, HitResult result) => hitObject.HitWindows != HitWindows.Empty && result.IsHit(); @@ -84,6 +105,11 @@ namespace osu.Game.Rulesets.Scoring /// public class UnstableRateCalculationResult { + /// + /// The last result index processed. For internal incremental calculation use. + /// + public int LastProcessedIndex = -1; + /// /// Total events processed. For internal incremental calculation use. /// diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index b6cfca58db..46c0371d9f 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Scoring /// /// /// This miss window should determine how early a hit can be before it is considered for judgement (as opposed to being ignored as - /// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time). + /// "too far in the future"). It should also define when a forced miss should be triggered (as a result of no user input in time). /// [Description(@"Miss")] [EnumMember(Value = "miss")] diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index a6a268fc78..f4d1fe1e14 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Scoring @@ -13,35 +12,26 @@ namespace osu.Game.Rulesets.Scoring /// /// A structure containing timing data for hit window based gameplay. /// - public class HitWindows + public abstract class HitWindows { - private static readonly DifficultyRange[] base_ranges = - { - new DifficultyRange(HitResult.Perfect, 22.4D, 19.4D, 13.9D), - new DifficultyRange(HitResult.Great, 64, 49, 34), - new DifficultyRange(HitResult.Good, 97, 82, 67), - new DifficultyRange(HitResult.Ok, 127, 112, 97), - new DifficultyRange(HitResult.Meh, 151, 136, 121), - new DifficultyRange(HitResult.Miss, 188, 173, 158), - }; - - private double perfect; - private double great; - private double good; - private double ok; - private double meh; - private double miss; - /// /// An empty with only and . /// No time values are provided (meaning instantaneous hit or miss). /// public static HitWindows Empty { get; } = new EmptyHitWindows(); - public HitWindows() + protected HitWindows() { - Debug.Assert(GetRanges().Any(r => r.Result == HitResult.Miss), $"{nameof(GetRanges)} should always contain {nameof(HitResult.Miss)}"); - Debug.Assert(GetRanges().Any(r => r.Result != HitResult.Miss), $"{nameof(GetRanges)} should always contain at least one result type other than {nameof(HitResult.Miss)}."); + ensureValidHitWindows(); + } + + [Conditional("DEBUG")] + private void ensureValidHitWindows() + { + var availableWindows = GetAllAvailableWindows().ToList(); + Debug.Assert(availableWindows.Any(r => r.result == HitResult.Miss), $"{nameof(GetAllAvailableWindows)} should always contain {nameof(HitResult.Miss)}"); + Debug.Assert(availableWindows.Any(r => r.result != HitResult.Miss), + $"{nameof(GetAllAvailableWindows)} should always contain at least one result type other than {nameof(HitResult.Miss)}."); } /// @@ -64,7 +54,7 @@ namespace osu.Game.Rulesets.Scoring /// public IEnumerable<(HitResult result, double length)> GetAllAvailableWindows() { - for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result) + for (var result = HitResult.Miss; result <= HitResult.Perfect; ++result) { if (IsHitResultAllowed(result)) yield return (result, WindowFor(result)); @@ -82,40 +72,7 @@ namespace osu.Game.Rulesets.Scoring /// Sets hit windows with values that correspond to a difficulty parameter. /// /// The parameter. - public void SetDifficulty(double difficulty) - { - foreach (var range in GetRanges()) - { - double value = IBeatmapDifficultyInfo.DifficultyRange(difficulty, (range.Min, range.Average, range.Max)); - - switch (range.Result) - { - case HitResult.Miss: - miss = value; - break; - - case HitResult.Meh: - meh = value; - break; - - case HitResult.Ok: - ok = value; - break; - - case HitResult.Good: - good = value; - break; - - case HitResult.Great: - great = value; - break; - - case HitResult.Perfect: - perfect = value; - break; - } - } - } + public abstract void SetDifficulty(double difficulty); /// /// Retrieves the for a time offset. @@ -141,32 +98,7 @@ namespace osu.Game.Rulesets.Scoring /// /// The expected . /// One half of the hit window for . - public double WindowFor(HitResult result) - { - switch (result) - { - case HitResult.Perfect: - return perfect; - - case HitResult.Great: - return great; - - case HitResult.Good: - return good; - - case HitResult.Ok: - return ok; - - case HitResult.Meh: - return meh; - - case HitResult.Miss: - return miss; - - default: - throw new ArgumentException("Unknown enum member", nameof(result)); - } - } + public abstract double WindowFor(HitResult result); /// /// Given a time offset, whether the can ever be hit in the future with a non- result. @@ -176,51 +108,13 @@ namespace osu.Game.Rulesets.Scoring /// Whether the can be hit at any point in the future from this time offset. public bool CanBeHit(double timeOffset) => timeOffset <= WindowFor(LowestSuccessfulHitResult()); - /// - /// Retrieve a valid list of s representing hit windows. - /// Defaults are provided but can be overridden to customise for a ruleset. - /// - protected virtual DifficultyRange[] GetRanges() => base_ranges; - private class EmptyHitWindows : HitWindows { - private static readonly DifficultyRange[] ranges = - { - new DifficultyRange(HitResult.Perfect, 0, 0, 0), - new DifficultyRange(HitResult.Miss, 0, 0, 0), - }; + public override bool IsHitResultAllowed(HitResult result) => true; - public override bool IsHitResultAllowed(HitResult result) - { - switch (result) - { - case HitResult.Perfect: - case HitResult.Miss: - return true; - } + public override void SetDifficulty(double difficulty) { } - return false; - } - - protected override DifficultyRange[] GetRanges() => ranges; - } - } - - public struct DifficultyRange - { - public readonly HitResult Result; - - public double Min; - public double Average; - public double Max; - - public DifficultyRange(HitResult result, double min, double average, double max) - { - Result = result; - - Min = min; - Average = average; - Max = max; + public override double WindowFor(HitResult result) => 0; } } } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 7b5af9beda..3663e7f008 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -202,7 +202,6 @@ namespace osu.Game.Rulesets.Scoring { Ruleset = ruleset; - Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += _ => updateRank(); Mods.ValueChanged += mods => @@ -238,7 +237,10 @@ namespace osu.Game.Rulesets.Scoring else if (result.Type.BreaksCombo()) Combo.Value = 0; + HighestCombo.Value = Math.Max(HighestCombo.Value, Combo.Value); + result.ComboAfterJudgement = Combo.Value; + result.HighestComboAfterJudgement = HighestCombo.Value; if (result.Judgement.MaxResult.AffectsAccuracy()) { @@ -281,8 +283,11 @@ namespace osu.Game.Rulesets.Scoring if (!TrackHitEvents) throw new InvalidOperationException(@$"Rewind is not supported when {nameof(TrackHitEvents)} is disabled."); - Combo.Value = result.ComboAtJudgement; - HighestCombo.Value = result.HighestComboAtJudgement; + // the reason this is written so funnily rather than just using `ComboAtJudgement` + // is to nullify impact of ordering when reverting concurrent judgement results + // (think mania and multiple judgements within a frame). + Combo.Value -= (result.ComboAfterJudgement - result.ComboAtJudgement); + HighestCombo.Value -= (result.HighestComboAfterJudgement - result.HighestComboAtJudgement); if (result.FailedAtJudgement && !ApplyNewJudgementsWhenFailed) return; diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index ebd84fd91b..31ff81456c 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -54,7 +54,12 @@ namespace osu.Game.Rulesets.UI /// /// The key conversion input manager for this DrawableRuleset. /// - protected PassThroughInputManager KeyBindingInputManager; + protected PassThroughInputManager KeyBindingInputManager { get; } + + /// + /// This configuration for this DrawableRuleset. + /// + protected IRulesetConfigManager Config { get; private set; } public override double GameplayStartTime => Objects.FirstOrDefault()?.StartTime - 2000 ?? 0; @@ -77,20 +82,25 @@ namespace osu.Game.Rulesets.UI public override IFrameStableClock FrameStableClock => frameStabilityContainer; + public override IEnumerable Objects => Beatmap.HitObjects; + + /// + /// The beatmap. + /// + [Cached(typeof(IBeatmap))] + public readonly Beatmap Beatmap; + + [Cached(typeof(IReadOnlyList))] + public sealed override IReadOnlyList Mods { get; } + + [Resolved(CanBeNull = true)] + private OnScreenDisplay onScreenDisplay { get; set; } + private readonly PlayfieldAdjustmentContainer playfieldAdjustmentContainer; - private bool allowBackwardsSeeks; - - public override bool AllowBackwardsSeeks - { - get => allowBackwardsSeeks; - set - { - allowBackwardsSeeks = value; - if (frameStabilityContainer != null) - frameStabilityContainer.AllowBackwardsSeeks = value; - } - } + private IDisposable configTracker; + private FrameStabilityContainer frameStabilityContainer; + private DrawableRulesetDependencies dependencies; private bool frameStablePlayback = true; @@ -105,25 +115,6 @@ namespace osu.Game.Rulesets.UI } } - /// - /// The beatmap. - /// - [Cached(typeof(IBeatmap))] - public readonly Beatmap Beatmap; - - public override IEnumerable Objects => Beatmap.HitObjects; - - protected IRulesetConfigManager Config { get; private set; } - - [Cached(typeof(IReadOnlyList))] - public sealed override IReadOnlyList Mods { get; } - - private FrameStabilityContainer frameStabilityContainer; - - private OnScreenDisplay onScreenDisplay; - - private DrawableRulesetDependencies dependencies; - /// /// Creates a ruleset visualisation for the provided ruleset and beatmap. /// @@ -156,6 +147,9 @@ namespace osu.Game.Rulesets.UI { base.LoadComplete(); + if (Config != null) + configTracker = onScreenDisplay?.BeginTracking(this, Config); + IsPaused.ValueChanged += paused => { if (HasReplayLoaded.Value) @@ -168,13 +162,7 @@ namespace osu.Game.Rulesets.UI protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { dependencies = new DrawableRulesetDependencies(Ruleset, base.CreateChildDependencies(parent)); - Config = dependencies.RulesetConfigManager; - - onScreenDisplay = dependencies.Get(); - if (Config != null) - onScreenDisplay?.BeginTracking(this, Config); - return dependencies; } @@ -189,7 +177,6 @@ namespace osu.Game.Rulesets.UI InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { FrameStablePlayback = FrameStablePlayback, - AllowBackwardsSeeks = AllowBackwardsSeeks, Children = new Drawable[] { FrameStableComponents, @@ -299,6 +286,7 @@ namespace osu.Game.Rulesets.UI if (score == null) { + NewResult -= emitImportantFrame; recordingInputManager.Recorder = null; return; } @@ -310,7 +298,10 @@ namespace osu.Game.Rulesets.UI recorder.ScreenSpaceToGamefield = Playfield.ScreenSpaceToGamefield; + NewResult += emitImportantFrame; recordingInputManager.Recorder = recorder; + + void emitImportantFrame(JudgementResult judgementResult) => recordingInputManager.Recorder?.RecordFrame(true); } public override void SetReplayScore(Score replayScore) @@ -404,11 +395,7 @@ namespace osu.Game.Rulesets.UI { base.Dispose(isDisposing); - if (Config != null) - { - onScreenDisplay?.StopTracking(this, Config); - Config = null; - } + configTracker?.Dispose(); // Dispose the components created by this dependency container. dependencies?.Dispose(); @@ -480,12 +467,6 @@ namespace osu.Game.Rulesets.UI /// internal abstract bool FrameStablePlayback { get; set; } - /// - /// When a replay is not attached, we usually block any backwards seeks. - /// This will bypass the check. Should only be used for tests. - /// - public abstract bool AllowBackwardsSeeks { get; set; } - /// /// The mods which are to be applied. /// @@ -577,6 +558,11 @@ namespace osu.Game.Rulesets.UI /// public virtual bool AllowGameplayOverlays => true; + /// + /// On mobile devices, this specifies whether this ruleset requires the device to be in portrait orientation. + /// + public virtual bool RequiresPortraitOrientation => false; + /// /// Sets a replay to be used, overriding local input. /// diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 02c5bbb27e..a875109474 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -157,6 +157,8 @@ namespace osu.Game.Rulesets.UI public IBindable AggregateTempo => throw new NotSupportedException(); + public void Invalidate(string name) => throw new NotSupportedException(); + public int PlaybackConcurrency { get => throw new NotSupportedException(); diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 92258f3fc9..990c1c839b 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; @@ -25,8 +26,7 @@ namespace osu.Game.Rulesets.UI { public ReplayInputHandler? ReplayInputHandler { get; set; } - public bool AllowBackwardsSeeks { get; set; } - private double? lastBackwardsSeekLogTime; + private int invalidBassTimeLogCount; /// /// The number of CPU milliseconds to spend at most during seek catch-up. @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.UI private readonly Bindable waitingOnFrames = new Bindable(); - private readonly double gameplayStartTime; + public double GameplayStartTime { get; } private IGameplayClock? parentGameplayClock; @@ -63,6 +63,9 @@ namespace osu.Game.Rulesets.UI /// private readonly FramedClock framedClock; + [Resolved] + private OsuGame? game { get; set; } + private readonly Stopwatch stopwatch = new Stopwatch(); /// @@ -85,7 +88,7 @@ namespace osu.Game.Rulesets.UI framedClock = new FramedClock(manualClock = new ManualClock()); - this.gameplayStartTime = gameplayStartTime; + GameplayStartTime = gameplayStartTime; } [BackgroundDependencyLoader(true)] @@ -154,23 +157,31 @@ namespace osu.Game.Rulesets.UI state = PlaybackState.NotValid; } - // This is a hotfix for https://github.com/ppy/osu/issues/26879 while we figure how the hell time is seeking - // backwards by 11,850 ms for some users during gameplay. + // TODO: replace IsDebugBuild with a framework flag which asserts we are in a test scene, interactively or otherwise. + bool allowReferenceClockSeeks = hasReplayAttached || DebugUtils.IsNUnitRunning || DebugUtils.IsDebugBuild || !FrameStablePlayback; + + // This is a hotfix for ongoing bass issues we are trying to resolve (see https://www.un4seen.com/forum/?topic=20482.msg145474#msg145474) // - // It basically says that "while we're running in frame stable mode, and don't have a replay attached, - // time should never go backwards". If it does, we stop running gameplay until it returns to normal. - if (!hasReplayAttached && FrameStablePlayback && proposedTime > referenceClock.CurrentTime && !AllowBackwardsSeeks) + // In testing this triggers *very* rarely even when set to super low values (10 ms). The cases we're worried about involve multi-second jumps. + // A difference of more than 500 ms seems like a sane number we should never exceed. + // + // Double-checking against the parent clock ensures we don't accidentally freeze time when the game stutters due to a long running frame. + if (!allowReferenceClockSeeks && Math.Abs(proposedTime - referenceClock.CurrentTime) > 500 && game?.Clock.ElapsedFrameTime <= 500) { - if (lastBackwardsSeekLogTime == null || Math.Abs(Clock.CurrentTime - lastBackwardsSeekLogTime.Value) > 1000) + if (invalidBassTimeLogCount < 10) { - lastBackwardsSeekLogTime = Clock.CurrentTime; - Logger.Log($"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"); + invalidBassTimeLogCount++; + Logger.Log("Ignoring likely invalid time value provided by BASS during gameplay"); + Logger.Log($"- provided: {referenceClock.CurrentTime:N2}"); + Logger.Log($"- expected: {proposedTime:N2}"); } state = PlaybackState.NotValid; return; } + invalidBassTimeLogCount = 0; + // 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) @@ -257,8 +268,8 @@ namespace osu.Game.Rulesets.UI return; } - if (manualClock.CurrentTime < gameplayStartTime) - manualClock.CurrentTime = proposedTime = Math.Min(gameplayStartTime, proposedTime); + if (manualClock.CurrentTime < GameplayStartTime) + manualClock.CurrentTime = proposedTime = Math.Min(GameplayStartTime, proposedTime); else if (Math.Abs(manualClock.CurrentTime - proposedTime) > sixty_frame_time * 1.2f) { proposedTime = proposedTime > manualClock.CurrentTime diff --git a/osu.Game/Rulesets/UI/IHasRecordingHandler.cs b/osu.Game/Rulesets/UI/IHasRecordingHandler.cs index f73398dd98..d4ba7cfb71 100644 --- a/osu.Game/Rulesets/UI/IHasRecordingHandler.cs +++ b/osu.Game/Rulesets/UI/IHasRecordingHandler.cs @@ -10,6 +10,6 @@ namespace osu.Game.Rulesets.UI /// public interface IHasRecordingHandler { - public ReplayRecorder? Recorder { set; } + ReplayRecorder? Recorder { get; set; } } } diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 5237425075..79cf073a42 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -10,11 +10,11 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; -using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.UI /// /// Display the specified mod at a fixed size. /// - public partial class ModIcon : Container, IHasTooltip + public partial class ModIcon : Container, IHasCustomTooltip { public readonly BindableBool Selected = new BindableBool(); @@ -34,12 +34,23 @@ namespace osu.Game.Rulesets.UI public static readonly Vector2 MOD_ICON_SIZE = new Vector2(80); - public virtual LocalisableString TooltipText => showTooltip ? ((mod as Mod)?.IconTooltip ?? mod.Name) : string.Empty; + public Mod? TooltipContent { get; private set; } private IMod mod; private readonly bool showTooltip; - private readonly bool showExtendedInformation; + + private bool showExtendedInformation; + + public bool ShowExtendedInformation + { + get => showExtendedInformation; + set + { + showExtendedInformation = value; + updateExtendedInformation(); + } + } public IMod Mod { @@ -59,6 +70,9 @@ namespace osu.Game.Rulesets.UI [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private OverlayColourProvider? colourProvider { get; set; } + private Color4 backgroundColour; private Sprite extendedBackground = null!; @@ -67,6 +81,11 @@ namespace osu.Game.Rulesets.UI private Container extendedContent = null!; + private Drawable adjustmentMarker = null!; + + private SpriteIcon cogBackground = null!; + private SpriteIcon cog = null!; + private ModSettingChangeTracker? modSettingsChangeTracker; /// @@ -125,7 +144,7 @@ namespace osu.Game.Rulesets.UI Origin = Anchor.CentreLeft, Name = "main content", Size = MOD_ICON_SIZE, - Children = new Drawable[] + Children = new[] { background = new Sprite { @@ -148,9 +167,39 @@ namespace osu.Game.Rulesets.UI { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Size = new Vector2(45), + RelativeSizeAxes = Axes.Both, + // the mod icon assets in `osu-resources` are sized such that they are flush with the hexagonal background with no shadow baked in. + // the `Icons/BeatmapDetails/mod-icon` asset (of size 135x100) has a shadow and some extra transparent pixels baked in. + // the hexagonal background on that asset, excluding its shadow and the transparent pixels, is 131px wide and 92px high. + // height is divided by 135 rather than by 100, because this entire component is square-sized. + Width = 131 / 135f, + Height = 92 / 135f, Icon = FontAwesome.Solid.Question }, + adjustmentMarker = new Container + { + Size = new Vector2(20), + Origin = Anchor.Centre, + Position = new Vector2(64, 14), + Children = new Drawable[] + { + cogBackground = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.Circle, + }, + cog = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Cog, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.6f), + } + } + }, } }, }; @@ -177,6 +226,7 @@ namespace osu.Game.Rulesets.UI modAcronym.Text = value.Acronym; modIcon.Icon = value.Icon ?? FontAwesome.Solid.Question; + TooltipContent = showTooltip ? value as Mod : null; if (value.Icon == null) { @@ -201,11 +251,18 @@ namespace osu.Game.Rulesets.UI extendedContent.Alpha = showExtended ? 1 : 0; extendedText.Text = mod.ExtendedIconInformation; + + if (mod.HasNonDefaultSettings) + adjustmentMarker.Show(); + else + adjustmentMarker.Hide(); } private void updateColour() { modAcronym.Colour = modIcon.Colour = Interpolation.ValueAt(0.1f, Colour4.Black, backgroundColour, 0, 1); + cogBackground.Colour = Interpolation.ValueAt(0.1f, Colour4.Black, backgroundColour, 0, 1); + cog.Colour = backgroundColour; extendedText.Colour = background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour; extendedBackground.Colour = Selected.Value ? backgroundColour.Darken(2.4f) : backgroundColour.Darken(2.8f); @@ -216,5 +273,7 @@ namespace osu.Game.Rulesets.UI base.Dispose(isDisposing); modSettingsChangeTracker?.Dispose(); } + + public ITooltip GetCustomTooltip() => new ModTooltip(colourProvider); } } diff --git a/osu.Game/Rulesets/UI/ModSwitchSmall.cs b/osu.Game/Rulesets/UI/ModSwitchSmall.cs index 6e96cc8e6f..c471a7f3f2 100644 --- a/osu.Game/Rulesets/UI/ModSwitchSmall.cs +++ b/osu.Game/Rulesets/UI/ModSwitchSmall.cs @@ -61,7 +61,6 @@ namespace osu.Game.Rulesets.UI AutoSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Spacing = new Vector2(0, 4), Direction = FillDirection.Vertical, Child = tinySwitch = new ModSwitchTiny(mod) { @@ -79,7 +78,9 @@ namespace osu.Game.Rulesets.UI { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Size = new Vector2(21), + Size = new Vector2(37, 26), + // arbitrary adjustment for better vertical alignment + Margin = new MarginPadding { Top = -1 }, Icon = mod.Icon.Value }); tinySwitch.Scale = new Vector2(0.3f); diff --git a/osu.Game/Rulesets/UI/ModTooltip.cs b/osu.Game/Rulesets/UI/ModTooltip.cs new file mode 100644 index 0000000000..6f60390798 --- /dev/null +++ b/osu.Game/Rulesets/UI/ModTooltip.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Rulesets.Mods; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.UI +{ + public partial class ModTooltip : VisibilityContainer, ITooltip + { + private readonly OverlayColourProvider colourProvider; + + private OsuSpriteText nameText = null!; + private TextFlowContainer settingsLabelsFlow = null!; + private TextFlowContainer settingsValuesFlow = null!; + + public ModTooltip(OverlayColourProvider? colourProvider = null) + { + this.colourProvider = colourProvider ?? new OverlayColourProvider(OverlayColourScheme.Aquamarine); + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = 7; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.2f), + Radius = 10f, + }; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(10f), + Spacing = new Vector2(20f, 0f), + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] + { + nameText = new OsuSpriteText + { + Font = OsuFont.Torus.With(size: 16f, weight: FontWeight.SemiBold), + Colour = colourProvider.Content1, + UseFullGlyphHeight = false, + }, + settingsLabelsFlow = new TextFlowContainer(t => + { + t.Font = OsuFont.Torus.With(size: 12f, weight: FontWeight.SemiBold); + }) + { + AutoSizeAxes = Axes.Both, + Colour = colourProvider.Content2, + }, + }, + }, + settingsValuesFlow = new TextFlowContainer(t => + { + t.Font = OsuFont.Torus.With(size: 12f, weight: FontWeight.SemiBold); + }) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Both, + Colour = colourProvider.Content1, + TextAnchor = Anchor.TopRight, + }, + }, + } + }; + } + + private (LocalisableString setting, LocalisableString value)[]? displayedSettings; + + public void SetContent(Mod content) + { + nameText.Text = content.Name; + + if (displayedSettings == null || !displayedSettings.SequenceEqual(content.SettingDescription)) + { + displayedSettings = content.SettingDescription.ToArray(); + + settingsLabelsFlow.Clear(); + settingsValuesFlow.Clear(); + + if (displayedSettings.Any()) + { + settingsLabelsFlow.Show(); + settingsValuesFlow.Show(); + + foreach (var part in displayedSettings) + { + settingsLabelsFlow.AddText(part.setting); + settingsLabelsFlow.NewLine(); + + settingsValuesFlow.AddText(part.value); + settingsValuesFlow.NewLine(); + } + } + else + { + settingsLabelsFlow.Hide(); + settingsValuesFlow.Hide(); + } + } + } + + protected override void PopIn() => this.FadeIn(300, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(300, Easing.OutQuint); + public void Move(Vector2 pos) => Position = pos; + } +} diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs index 28e25c72e1..8829c15a21 100644 --- a/osu.Game/Rulesets/UI/ReplayRecorder.cs +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -27,7 +27,10 @@ namespace osu.Game.Rulesets.UI private InputManager inputManager; - public int RecordFrameRate = 60; + /// + /// The frame rate to record replays at. + /// + public int RecordFrameRate { get; set; } = 60; [Resolved] private SpectatorClient spectatorClient { get; set; } @@ -37,8 +40,6 @@ namespace osu.Game.Rulesets.UI this.target = target; RelativeSizeAxes = Axes.Both; - - Depth = float.MinValue; } protected override void LoadComplete() @@ -50,33 +51,33 @@ namespace osu.Game.Rulesets.UI protected override void Update() { base.Update(); - recordFrame(false); + RecordFrame(false); } protected override bool OnMouseMove(MouseMoveEvent e) { - recordFrame(false); + RecordFrame(false); return base.OnMouseMove(e); } public bool OnPressed(KeyBindingPressEvent e) { pressedActions.Add(e.Action); - recordFrame(true); + RecordFrame(true); return false; } public void OnReleased(KeyBindingReleaseEvent e) { pressedActions.Remove(e.Action); - recordFrame(true); + RecordFrame(true); } - private void recordFrame(bool important) + public override void RecordFrame(bool important) { var last = target.Replay.Frames.LastOrDefault(); - if (!important && last != null && Time.Current - last.Time < (1000d / RecordFrameRate)) + if (!important && last != null && Time.Current - last.Time < (1000d / RecordFrameRate) * Clock.Rate) return; var position = ScreenSpaceToGamefield?.Invoke(inputManager.CurrentState.Mouse.Position) ?? inputManager.CurrentState.Mouse.Position; @@ -85,8 +86,14 @@ namespace osu.Game.Rulesets.UI if (frame != null) { - target.Replay.Frames.Add(frame); + // this reduces redundancy of frames in the resulting replay. + if (last?.IsEquivalentTo(frame) == true) + target.Replay.Frames[^1] = frame; + else + target.Replay.Frames.Add(frame); + // the above de-duplication is done at `FrameDataBundle` level in `SpectatorClient`. + // it's not 100% matching because of the possibility of duplicated frames crossing a bundle boundary, but it's close and simple enough. spectatorClient?.HandleFrame(frame); } } @@ -97,5 +104,7 @@ namespace osu.Game.Rulesets.UI public abstract partial class ReplayRecorder : Component { public Func ScreenSpaceToGamefield; + + public abstract void RecordFrame(bool important); } } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 31c7c34572..aa2c740c5b 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -39,6 +39,7 @@ namespace osu.Game.Rulesets.UI public ReplayRecorder? Recorder { + get => recorder; set { if (value == recorder) diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index ba3a9bd483..f0b9876b51 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -21,6 +19,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Timing; using osu.Game.Rulesets.UI.Scrolling.Algorithms; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.UI.Scrolling { @@ -69,6 +68,12 @@ namespace osu.Game.Rulesets.UI.Scrolling /// protected virtual bool UserScrollSpeedAdjustment => true; + /// + /// Whether at the current point in time, whether scroll speed adjustments should be applied to gameplay. + /// This can potentially become false at some point during gameplay for game balance reasons. + /// + protected bool AllowScrollSpeedAdjustment => UserScrollSpeedAdjustment && player?.AllowCriticalSettingsAdjustment != false; + /// /// Whether beat lengths should scale relative to the most common beat length in the . /// @@ -84,7 +89,10 @@ namespace osu.Game.Rulesets.UI.Scrolling [Cached(Type = typeof(IScrollingInfo))] private readonly LocalScrollingInfo scrollingInfo; - protected DrawableScrollingRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + [Resolved] + private Player? player { get; set; } + + protected DrawableScrollingRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { scrollingInfo = new LocalScrollingInfo(); @@ -195,28 +203,30 @@ namespace osu.Game.Rulesets.UI.Scrolling /// Adjusts the scroll speed of s. /// /// 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); + protected virtual void AdjustScrollSpeed(int amount) + { + this.TransformBindableTo(TimeRange, TimeRange.Value - amount * time_span_step, 200, Easing.OutQuint); + } public bool OnPressed(KeyBindingPressEvent e) { - if (!UserScrollSpeedAdjustment) - return false; - switch (e.Action) { case GlobalAction.IncreaseScrollSpeed: - AdjustScrollSpeed(1); + if (AllowScrollSpeedAdjustment) + AdjustScrollSpeed(1); return true; case GlobalAction.DecreaseScrollSpeed: - AdjustScrollSpeed(-1); + if (AllowScrollSpeedAdjustment) + AdjustScrollSpeed(-1); return true; } return false; } - private ScheduledDelegate scheduledScrollSpeedAdjustment; + private ScheduledDelegate? scheduledScrollSpeedAdjustment; public void OnReleased(KeyBindingReleaseEvent e) { diff --git a/osu.Game/Rulesets/UI/Scrolling/ISupportConstantAlgorithmToggle.cs b/osu.Game/Rulesets/UI/Scrolling/ISupportConstantAlgorithmToggle.cs index aaa635350e..fd67548909 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ISupportConstantAlgorithmToggle.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ISupportConstantAlgorithmToggle.cs @@ -10,6 +10,6 @@ namespace osu.Game.Rulesets.UI.Scrolling /// public interface ISupportConstantAlgorithmToggle : IDrawableScrollingRuleset { - public BindableBool ShowSpeedChanges { get; } + BindableBool ShowSpeedChanges { get; } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 7841e65935..8b0076afa1 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -247,7 +247,12 @@ namespace osu.Game.Rulesets.UI.Scrolling // It is required that we set a lifetime end here to ensure that in scenarios like loading a Player instance to a seeked // location in a beatmap doesn't churn every hit object into a DrawableHitObject. Even in a pooled scenario, the overhead // of this can be quite crippling. - entry.LifetimeEnd = entry.HitObject.GetEndTime() + timeRange.Value; + // + // However, additionally do not attempt to alter lifetime of judged entries. + // This is to prevent freak accidents like objects suddenly becoming alive because of this estimate assigning a later lifetime + // than the object itself decided it should have when it underwent judgement. + if (!entry.Judged) + entry.LifetimeEnd = entry.HitObject.GetEndTime() + timeRange.Value; } private void updateLayoutRecursive(DrawableHitObject hitObject, double? parentHitObjectStartTime = null) diff --git a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs index c99f104418..8247dc60cb 100644 --- a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs +++ b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs @@ -49,6 +49,9 @@ namespace osu.Game.Scoring.Legacy [JsonProperty("total_score_without_mods")] public long? TotalScoreWithoutMods { get; set; } + [JsonProperty("pauses")] + public int[] Pauses { get; set; } = []; + public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo { OnlineID = score.OnlineID, @@ -59,6 +62,7 @@ namespace osu.Game.Scoring.Legacy Rank = score.Rank, UserID = score.User.OnlineID, TotalScoreWithoutMods = score.TotalScoreWithoutMods > 0 ? score.TotalScoreWithoutMods : null, + Pauses = score.Pauses.ToArray(), }; } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 6ad118547b..393df65cc8 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -13,6 +13,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO.Legacy; using osu.Game.Online.API.Requests.Responses; using osu.Game.Replays; @@ -31,7 +32,7 @@ namespace osu.Game.Scoring.Legacy private IBeatmap currentBeatmap; private Ruleset currentRuleset; - private float beatmapOffset; + private long beatmapOffset; public Score Parse(Stream stream) { @@ -96,7 +97,7 @@ namespace osu.Game.Scoring.Legacy scoreInfo.BeatmapInfo = currentBeatmap.BeatmapInfo; // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing. - beatmapOffset = currentBeatmap.BeatmapInfo.BeatmapVersion < 5 ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; + beatmapOffset = currentBeatmap.BeatmapVersion < 5 ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; /* score.HpGraphString = */ sr.ReadString(); @@ -110,6 +111,9 @@ namespace osu.Game.Scoring.Legacy else if (version >= 20121008) scoreInfo.LegacyOnlineID = sr.ReadInt32(); + if (scoreInfo.LegacyOnlineID == 0) + scoreInfo.LegacyOnlineID = -1; + byte[] compressedScoreInfo = null; if (version >= 30000001) @@ -139,6 +143,8 @@ namespace osu.Game.Scoring.Legacy score.ScoreInfo.TotalScoreWithoutMods = totalScoreWithoutMods; else PopulateTotalScoreWithoutMods(score.ScoreInfo); + + score.ScoreInfo.Pauses.AddRange(readScore.Pauses); }); } } @@ -262,7 +268,7 @@ namespace osu.Game.Scoring.Legacy private void readLegacyReplay(Replay replay, StreamReader reader) { - float lastTime = beatmapOffset; + long lastTime = beatmapOffset; var legacyFrames = new List(); string[] frames = reader.ReadToEnd().Split(','); @@ -283,7 +289,23 @@ namespace osu.Game.Scoring.Legacy // In mania, mouseX encodes the pressed keys in the lower 20 bits int mouseXParseLimit = currentRuleset.RulesetInfo.OnlineID == 3 ? (1 << 20) - 1 : Parsing.MAX_COORDINATE_VALUE; - float diff = Parsing.ParseFloat(split[0]); + // the legacy replay format as defined by stable expects frame delta times + // ('delta time' here meaning the amount of time between consecutive frames) + // to be integral and does not allow fractional values. + // one particular reason why this matters is that integral deltas + // avoid nasty floating point traps like accumulation error from summation or round-off error. + // however, there was a period in lazer's lifetime wherein lazer emitted replays + // with fractional (float) frame deltas, up until https://github.com/ppy/osu/pull/12583. + // despite the fact that gameplay mechanics changed multiple times since + // and the replay isn't going to play back anywhere near accurately anyway, + // no mistakes are ever forgiven, thus this attempts to parse the delta as an integer once, + // and if that fails, tries again as float. + // notably this cannot just be `(int)Parsing.ParseFloat(split[0])`, because that can lose information + // (`float` numbers have 24 bits of significand precision, which is not enough to accurately represent every possible value of `int`). + int diff; + if (!int.TryParse(split[0], out diff)) + diff = (int)Math.Round(Parsing.ParseFloat(split[0])); + float mouseX = Parsing.ParseFloat(split[1], mouseXParseLimit); float mouseY = Parsing.ParseFloat(split[2], Parsing.MAX_COORDINATE_VALUE); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index 0f00cce080..b575c02337 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -142,7 +142,7 @@ namespace osu.Game.Scoring.Legacy StringBuilder replayData = new StringBuilder(); // As this is baked into hitobject timing (see `LegacyBeatmapDecoder`) we also need to apply this to replay frame timing. - double offset = beatmap?.BeatmapInfo.BeatmapVersion < 5 ? -LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; + double offset = beatmap?.BeatmapVersion < 5 ? -LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; int lastTime = 0; diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index 23624401e2..664f1fd4ab 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring.Legacy @@ -20,6 +21,9 @@ namespace osu.Game.Scoring.Legacy public static long GetDisplayScore(this SoloScoreInfo soloScoreInfo, ScoringMode mode) => getDisplayScore(soloScoreInfo.RulesetID, soloScoreInfo.TotalScore, mode, soloScoreInfo.MaximumStatistics); + public static long GetDisplayScore(this MultiplayerScore multiplayerScore, ScoringMode mode) + => getDisplayScore(multiplayerScore.RulesetId, multiplayerScore.TotalScore, mode, multiplayerScore.MaximumStatistics); + private static long getDisplayScore(int rulesetId, long score, ScoringMode mode, IReadOnlyDictionary maximumStatistics) { if (mode == ScoringMode.Standardised) diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 69c53af16f..55b172526f 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -17,6 +17,7 @@ using osu.Game.Scoring.Legacy; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Utils; using Realms; namespace osu.Game.Scoring @@ -58,7 +59,7 @@ namespace osu.Game.Scoring { // In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap. var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); - req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, notFound.Hash)); + req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, notFound.Hash, archive)); api.Queue(req); } @@ -90,6 +91,9 @@ namespace osu.Game.Scoring ArgumentNullException.ThrowIfNull(model.BeatmapInfo); ArgumentNullException.ThrowIfNull(model.Ruleset); + if (!ModUtils.CheckCompatibleSet(model.Mods)) + throw new InvalidOperationException(@"The score specifies an incompatible set of mods!"); + if (string.IsNullOrEmpty(model.StatisticsJson)) model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index a3dabc7945..9e10b93168 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -155,6 +155,8 @@ namespace osu.Game.Scoring [MapTo("MaximumStatistics")] public string MaximumStatisticsJson { get; set; } = string.Empty; + public IList Pauses { get; } = null!; + public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null) { Ruleset = ruleset ?? new RulesetInfo(); diff --git a/osu.Game/Scoring/ScoreInfoExtensions.cs b/osu.Game/Scoring/ScoreInfoExtensions.cs index 6e57a9fd0b..2eec0399d6 100644 --- a/osu.Game/Scoring/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/ScoreInfoExtensions.cs @@ -1,10 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Models; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using Realms; namespace osu.Game.Scoring { @@ -26,11 +31,59 @@ namespace osu.Game.Scoring // Local scores may not have an online ID. Fall back to date in these cases. .ThenBy(s => s.Date); + /// + /// Orders an array of s by the selected . + /// + /// The array of s to reorder. + /// The attribute to sort the scores by. + /// The given ordered by the selected mode. + public static IEnumerable OrderByCriteria(this IEnumerable scores, LeaderboardSortMode leaderboardSortMode) + { + switch (leaderboardSortMode) + { + case LeaderboardSortMode.Score: + return scores.OrderByDescending(s => s.TotalScore); + + case LeaderboardSortMode.Accuracy: + return scores.OrderByDescending(s => s.Accuracy).ThenByDescending(s => s.TotalScore); + + case LeaderboardSortMode.MaxCombo: + return scores.OrderByDescending(s => s.MaxCombo).ThenByDescending(s => s.TotalScore); + + case LeaderboardSortMode.Misses: + return scores.OrderBy(s => s.Statistics.GetValueOrDefault(HitResult.Miss, 0)).ThenByDescending(s => s.TotalScore); + + case LeaderboardSortMode.Date: + return scores.OrderByDescending(s => s.Date); + + default: + throw new ArgumentOutOfRangeException(nameof(leaderboardSortMode), leaderboardSortMode, null); + } + } + /// /// Retrieves the maximum achievable combo for the provided score. /// /// The to compute the maximum achievable combo for. /// The maximum achievable combo. public static int GetMaximumAchievableCombo(this ScoreInfo score) => score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Sum(kvp => kvp.Value); + + /// + /// Performs a realm filter that returns all scores that belong to the user with the given . + /// (for guests) is supported. + /// + /// + /// All guest scores (with user ID of ), + /// as well as scores of unknown provenance (with default user ID of 1, see ), + /// will be treated as if they belong to the local user. + /// This may not be necessarily considered fully correct in some circumstances, but in most cases it is the desired effect. + /// + public static IQueryable GetAllLocalScoresForUser(this Realm realm, int? userId) + { + return realm.All() + .Filter($@"({nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0 || {nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} <= 1)" + + $@" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + + $@" && {nameof(ScoreInfo.DeletePending)} == false", userId); + } } } diff --git a/osu.Game/Scoring/ScoreRank.cs b/osu.Game/Scoring/ScoreRank.cs index 957cfc9b95..7e44e46471 100644 --- a/osu.Game/Scoring/ScoreRank.cs +++ b/osu.Game/Scoring/ScoreRank.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -10,40 +9,31 @@ namespace osu.Game.Scoring public enum ScoreRank { // TODO: Localisable? - [Description(@"F")] F = -1, [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+")] // ReSharper disable once InconsistentNaming SH, [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankX))] - [Description(@"SS")] X, [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankXH))] - [Description(@"SS+")] // ReSharper disable once InconsistentNaming XH, } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs index 185e2cab99..cadae8a5d3 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs @@ -101,18 +101,6 @@ namespace osu.Game.Screens.Backgrounds } } - /// - /// Reloads beatmap's background. - /// - public void RefreshBackground() - { - Schedule(() => - { - cancellationSource?.Cancel(); - LoadComponentAsync(new BeatmapBackground(beatmap), switchBackground, (cancellationSource = new CancellationTokenSource()).Token); - }); - } - private void switchBackground(BeatmapBackground b) { float newDepth = 0; @@ -120,12 +108,12 @@ namespace osu.Game.Screens.Backgrounds if (Background != null) { newDepth = Background.Depth + 1; - Background.FinishTransforms(); Background.FadeOut(250); Background.Expire(); } b.Depth = newDepth; + b.FadeInFromZero(500, Easing.OutQuint); dimmable.Background = Background = b; } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 7be96718bd..82bfd23801 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -163,6 +163,7 @@ namespace osu.Game.Screens.Backgrounds case TrianglesSkin: case ArgonSkin: case DefaultLegacySkin: + case RetroSkin: // default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them. break; diff --git a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs new file mode 100644 index 0000000000..24b582b71b --- /dev/null +++ b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.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 System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Screens.Backgrounds +{ + public partial class EditorBackgroundScreen : BackgroundScreen + { + private readonly Container dimContainer; + + private CancellationTokenSource? cancellationTokenSource; + private Bindable dimLevel = null!; + private Bindable showStoryboard = null!; + + private BeatmapBackground background = null!; + private Container storyboardContainer = null!; + + private IFrameBasedClock? clockSource; + + // We retrieve IBindable from our dependency cache instead of passing WorkingBeatmap directly into EditorBackgroundScreen. + // Otherwise, DummyWorkingBeatmap will be erroneously passed in whenever creating a new beatmap (since the Schedule() in the Editor that populates + // a new WorkingBeatmap with correct values generally runs after EditorBackgroundScreen is created), which causes any background changes to not be displayed. + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public EditorBackgroundScreen() + { + InternalChild = dimContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + dimContainer.AddRange(createContent()); + background = dimContainer.OfType().Single(); + storyboardContainer = dimContainer.OfType().Single(); + + dimLevel = config.GetBindable(OsuSetting.EditorDim); + showStoryboard = config.GetBindable(OsuSetting.EditorShowStoryboard); + } + + private IEnumerable createContent() => + [ + new BeatmapBackground(beatmap.Value) { RelativeSizeAxes = Axes.Both, }, + // this kooky container nesting is here because the storyboard needs a custom clock + // but also needs it on an isolated-enough level that doesn't break screen stack expiry logic (which happens if the clock was put on `this`), + // or doesn't make it literally impossible to fade the storyboard in/out in real time (which happens if the fade transforms were to be applied directly to the storyboard). + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new DrawableStoryboard(beatmap.Value.Storyboard) + { + Clock = clockSource ?? Clock, + } + } + ]; + + protected override void LoadComplete() + { + base.LoadComplete(); + + dimLevel.BindValueChanged(_ => dimContainer.FadeColour(OsuColour.Gray(1 - dimLevel.Value), 500, Easing.OutQuint), true); + showStoryboard.BindValueChanged(_ => updateState()); + updateState(0); + } + + private void updateState(double duration = 500) + { + storyboardContainer.FadeTo(showStoryboard.Value ? 1 : 0, duration, Easing.OutQuint); + // yes, this causes overdraw, but is also a (crude) fix for bad-looking transitions on screen entry + // caused by the previous background on the background stack poking out from under this one and then instantly fading out + background.FadeColour(beatmap.Value.Storyboard.ReplacesBackground && showStoryboard.Value ? Colour4.Black : Colour4.White, duration, Easing.OutQuint); + } + + public void ChangeClockSource(IFrameBasedClock frameBasedClock) + { + clockSource = frameBasedClock; + if (IsLoaded) + storyboardContainer.Child.Clock = frameBasedClock; + } + + public void RefreshBackground() + { + cancellationTokenSource?.Cancel(); + LoadComponentsAsync(createContent(), loaded => + { + dimContainer.Clear(); + dimContainer.AddRange(loaded); + + background = dimContainer.OfType().Single(); + storyboardContainer = dimContainer.OfType().Single(); + updateState(0); + }, (cancellationTokenSource = new CancellationTokenSource()).Token); + } + + public override bool Equals(BackgroundScreen? other) + { + if (other is not EditorBackgroundScreen otherBeatmapBackground) + return false; + + return base.Equals(other) && beatmap == otherBeatmapBackground.beatmap; + } + } +} diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index bd9c9bab9a..83acc2622f 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Edit { public class BindableBeatDivisor : BindableInt { - public static readonly int[] PREDEFINED_DIVISORS = { 1, 2, 3, 4, 6, 8, 12, 16 }; + public static readonly int[] PREDEFINED_DIVISORS = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 16 }; public const int MINIMUM_DIVISOR = 1; public const int MAXIMUM_DIVISOR = 64; @@ -129,6 +129,11 @@ namespace osu.Game.Screens.Edit case 12: return colours.YellowDarker; + case 5: + case 7: + case 9: + return colours.GreenLight; + default: return Color4.Red; } diff --git a/osu.Game/Screens/Edit/BookmarkController.cs b/osu.Game/Screens/Edit/BookmarkController.cs new file mode 100644 index 0000000000..80e77364e5 --- /dev/null +++ b/osu.Game/Screens/Edit/BookmarkController.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Screens.Edit.Components.Menus; + +namespace osu.Game.Screens.Edit +{ + public partial class BookmarkController : Component, IKeyBindingHandler + { + /// + /// Bookmarks menu item (with submenu containing options). Should be added to the 's global menu. + /// + public EditorMenuItem Menu { get; private set; } + + [Resolved] + private EditorClock clock { get; set; } = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + private readonly BindableList bookmarks = new BindableList(); + + private readonly EditorMenuItem removeBookmarkMenuItem; + private readonly EditorMenuItem seekToPreviousBookmarkMenuItem; + private readonly EditorMenuItem seekToNextBookmarkMenuItem; + private readonly EditorMenuItem resetBookmarkMenuItem; + + public BookmarkController() + { + Menu = new EditorMenuItem(EditorStrings.Bookmarks) + { + Items = new MenuItem[] + { + new EditorMenuItem(EditorStrings.AddBookmark, MenuItemType.Standard, addBookmarkAtCurrentTime) + { + Hotkey = new Hotkey(GlobalAction.EditorAddBookmark), + }, + removeBookmarkMenuItem = new EditorMenuItem(EditorStrings.RemoveClosestBookmark, MenuItemType.Destructive, removeClosestBookmark) + { + Hotkey = new Hotkey(GlobalAction.EditorRemoveClosestBookmark) + }, + seekToPreviousBookmarkMenuItem = new EditorMenuItem(EditorStrings.SeekToPreviousBookmark, MenuItemType.Standard, () => seekBookmark(-1)) + { + Hotkey = new Hotkey(GlobalAction.EditorSeekToPreviousBookmark) + }, + seekToNextBookmarkMenuItem = new EditorMenuItem(EditorStrings.SeekToNextBookmark, MenuItemType.Standard, () => seekBookmark(1)) + { + Hotkey = new Hotkey(GlobalAction.EditorSeekToNextBookmark) + }, + resetBookmarkMenuItem = new EditorMenuItem(EditorStrings.ResetBookmarks, MenuItemType.Destructive, () => dialogOverlay?.Push(new BookmarkResetDialog(editorBeatmap))) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + bookmarks.BindTo(editorBeatmap.Bookmarks); + } + + protected override void Update() + { + base.Update(); + + bool hasAnyBookmark = bookmarks.Count > 0; + bool hasBookmarkCloseEnoughForDeletion = bookmarks.Any(b => Math.Abs(b - clock.CurrentTimeAccurate) < 2000); + + removeBookmarkMenuItem.Action.Disabled = !hasBookmarkCloseEnoughForDeletion; + seekToPreviousBookmarkMenuItem.Action.Disabled = !hasAnyBookmark; + seekToNextBookmarkMenuItem.Action.Disabled = !hasAnyBookmark; + resetBookmarkMenuItem.Action.Disabled = !hasAnyBookmark; + } + + private void addBookmarkAtCurrentTime() + { + int bookmark = (int)clock.CurrentTimeAccurate; + int idx = bookmarks.BinarySearch(bookmark); + if (idx < 0) + bookmarks.Insert(~idx, bookmark); + } + + private void removeClosestBookmark() + { + if (removeBookmarkMenuItem.Action.Disabled) + return; + + int closestBookmark = bookmarks.MinBy(b => Math.Abs(b - clock.CurrentTimeAccurate)); + bookmarks.Remove(closestBookmark); + } + + private void seekBookmark(int direction) + { + int? targetBookmark = direction < 1 + ? bookmarks.Cast().LastOrDefault(b => b < clock.CurrentTimeAccurate) + : bookmarks.Cast().FirstOrDefault(b => b > clock.CurrentTimeAccurate); + + if (targetBookmark != null) + clock.SeekSmoothlyTo(targetBookmark.Value); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorSeekToPreviousBookmark: + seekBookmark(-1); + return true; + + case GlobalAction.EditorSeekToNextBookmark: + seekBookmark(1); + return true; + } + + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.EditorAddBookmark: + addBookmarkAtCurrentTime(); + return true; + + case GlobalAction.EditorRemoveClosestBookmark: + removeClosestBookmark(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game/Screens/Edit/BookmarkResetDialog.cs b/osu.Game/Screens/Edit/BookmarkResetDialog.cs new file mode 100644 index 0000000000..48a0202c86 --- /dev/null +++ b/osu.Game/Screens/Edit/BookmarkResetDialog.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public partial class BookmarkResetDialog : DeletionDialog + { + private readonly EditorBeatmap editor; + + public BookmarkResetDialog(EditorBeatmap editorBeatmap) + { + editor = editorBeatmap; + BodyText = "All Bookmarks"; + } + + [BackgroundDependencyLoader] + private void load() + { + DangerousAction = () => editor.Bookmarks.Clear(); + } + } +} + diff --git a/osu.Game/Screens/Edit/BottomBar.cs b/osu.Game/Screens/Edit/BottomBar.cs index 6af8217d41..49f3d704bc 100644 --- a/osu.Game/Screens/Edit/BottomBar.cs +++ b/osu.Game/Screens/Edit/BottomBar.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Input.Events; using osu.Framework.Testing; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Components; @@ -92,5 +93,8 @@ namespace osu.Game.Screens.Edit } }, true); } + + protected override bool OnMouseDown(MouseDownEvent e) => true; + protected override bool OnClick(ClickEvent e) => true; } } diff --git a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs index da71457004..37337bc79f 100644 --- a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs +++ b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,8 +17,6 @@ namespace osu.Game.Screens.Edit.Components protected readonly IBindable Beatmap = new Bindable(); - protected readonly IBindable Track = new Bindable(); - public readonly Drawable Background; private readonly Container content; @@ -45,10 +42,9 @@ namespace osu.Game.Screens.Edit.Components } [BackgroundDependencyLoader] - private void load(IBindable beatmap, EditorClock clock) + private void load(IBindable beatmap) { Beatmap.BindTo(beatmap); - Track.BindTo(clock.Track); } } } diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index 9fe6160ab4..01d777cdc6 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -75,7 +76,7 @@ namespace osu.Game.Screens.Edit.Components } }; - Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Tempo, tempoAdjustment), true); + editorClock.AudioAdjustments.AddAdjustment(AdjustableProperty.Tempo, tempoAdjustment); if (editor != null) currentScreenMode.BindTo(editor.Mode); @@ -105,7 +106,8 @@ namespace osu.Game.Screens.Edit.Components protected override void Dispose(bool isDisposing) { - Track.Value?.RemoveAdjustment(AdjustableProperty.Tempo, tempoAdjustment); + if (editorClock.IsNotNull()) + editorClock.AudioAdjustments.RemoveAdjustment(AdjustableProperty.Tempo, tempoAdjustment); base.Dispose(isDisposing); } @@ -148,7 +150,7 @@ namespace osu.Game.Screens.Edit.Components public LocalisableString TooltipText { get; set; } } - private partial class PlaybackTabControl : OsuTabControl + public partial class PlaybackTabControl : OsuTabControl { private static readonly double[] tempo_values = { 0.25, 0.5, 0.75, 1 }; diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index fcbc719f46..7b36b5f957 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -16,23 +19,38 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.TernaryButtons { - public partial class DrawableTernaryButton : OsuButton, IHasTooltip + public partial class DrawableTernaryButton : OsuButton, IHasTooltip, IHasCurrentValue { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public required LocalisableString Description + { + get => Text; + set => Text = value; + } + + public LocalisableString TooltipText { get; set; } + + /// + /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. + /// + public Func? CreateIcon { get; init; } + private Color4 defaultBackgroundColour; private Color4 defaultIconColour; private Color4 selectedBackgroundColour; private Color4 selectedIconColour; - protected Drawable Icon { get; private set; } = null!; + public Drawable Icon { get; private set; } = null!; - public readonly TernaryButton Button; - - public DrawableTernaryButton(TernaryButton button) + public DrawableTernaryButton() { - Button = button; - - Text = button.Description; - RelativeSizeAxes = Axes.X; } @@ -45,7 +63,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons defaultIconColour = defaultBackgroundColour.Darken(0.5f); selectedIconColour = selectedBackgroundColour.Lighten(0.5f); - Add(Icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => + Add(Icon = (CreateIcon?.Invoke() ?? new Circle()).With(b => { b.Blending = BlendingParameters.Additive; b.Anchor = Anchor.CentreLeft; @@ -59,18 +77,32 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { base.LoadComplete(); - Button.Bindable.BindValueChanged(_ => updateSelectionState(), true); - Button.Enabled.BindTo(Enabled); + current.BindValueChanged(_ => updateSelectionState(), true); Action = onAction; } private void onAction() { - if (!Button.Enabled.Value) + if (!Enabled.Value) return; - Button.Toggle(); + Toggle(); + } + + public void Toggle() + { + switch (Current.Value) + { + case TernaryState.False: + case TernaryState.Indeterminate: + Current.Value = TernaryState.True; + break; + + case TernaryState.True: + Current.Value = TernaryState.False; + break; + } } private void updateSelectionState() @@ -78,7 +110,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons if (!IsLoaded) return; - switch (Button.Bindable.Value) + switch (Current.Value) { case TernaryState.Indeterminate: Icon.Colour = selectedIconColour.Darken(0.5f); @@ -104,7 +136,5 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons Anchor = Anchor.CentreLeft, X = 40f }; - - public LocalisableString TooltipText => Button.Tooltip; } } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs new file mode 100644 index 0000000000..259fda70c5 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -0,0 +1,290 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +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.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + public partial class NewComboTernaryButton : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + private readonly BindableList selectedHitObjects = new BindableList(); + private readonly BindableList comboColours = new BindableList(); + + private readonly Bindable expanded = new Bindable(true); + + private Container mainButtonContainer = null!; + private ColourPickerButton pickerButton = null!; + private DrawableTernaryButton mainButton = null!; + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap, IExpandingContainer? expandableParent) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] + { + mainButtonContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = mainButton = new DrawableTernaryButton + { + Current = Current, + Description = "New combo", + CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }, + }, + }, + pickerButton = new ColourPickerButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + ComboColours = { BindTarget = comboColours } + } + }; + + selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects); + if (editorBeatmap.BeatmapSkin != null) + comboColours.BindTo(editorBeatmap.BeatmapSkin.ComboColours); + + if (expandableParent != null) + expanded.BindTo(expandableParent.Expanded); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedHitObjects.BindCollectionChanged((_, _) => updateState()); + comboColours.BindCollectionChanged((_, _) => updateState()); + expanded.BindValueChanged(_ => updateState()); + Current.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + if (Current.Value == TernaryState.True && selectedHitObjects.Count == 1 && selectedHitObjects.Single() is IHasComboInformation hasCombo && comboColours.Count > 1) + { + float targetPickerButtonWidth = expanded.Value ? 25 : 10; + + pickerButton.ResizeWidthTo(targetPickerButtonWidth, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + pickerButton.SelectedHitObject.Value = hasCombo; + pickerButton.Icon.Alpha = expanded.Value ? 1 : 0; + + mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding { Right = targetPickerButtonWidth + 5 }, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + mainButton.Icon.MoveToX(expanded.Value ? 10 : 2.5f, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + } + else + { + pickerButton.ResizeWidthTo(0, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + + mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding(), ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + mainButton.Icon.MoveToX(10, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + } + } + + private partial class ColourPickerButton : OsuButton, IHasPopover + { + public BindableList ComboColours { get; } = new BindableList(); + public Bindable SelectedHitObject { get; } = new Bindable(); + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public SpriteIcon Icon { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Add(Icon = new SpriteIcon + { + Icon = FontAwesome.Solid.Palette, + Size = new Vector2(16), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ComboColours.BindCollectionChanged((_, _) => updateState()); + SelectedHitObject.BindValueChanged(val => + { + if (val.OldValue != null) + val.OldValue.ComboIndexWithOffsetsBindable.ValueChanged -= onComboIndexChanged; + + updateState(); + + if (val.NewValue != null) + val.NewValue.ComboIndexWithOffsetsBindable.ValueChanged += onComboIndexChanged; + }, true); + } + + private void onComboIndexChanged(ValueChangedEvent _) => updateState(); + + private void updateState() + { + Enabled.Value = SelectedHitObject.Value != null; + + if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1 || !SelectedHitObject.Value.NewCombo) + { + BackgroundColour = colourProvider.Background3; + Icon.Colour = BackgroundColour.Darken(0.5f); + Icon.Blending = BlendingParameters.Additive; + } + else + { + BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)]; + Icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); + Icon.Blending = BlendingParameters.Inherit; + } + } + + public Popover GetPopover() => new ComboColourPalettePopover(ComboColours, SelectedHitObject.Value.AsNonNull(), editorBeatmap); + } + + private partial class ComboColourPalettePopover : OsuPopover + { + private readonly IReadOnlyList comboColours; + private readonly IHasComboInformation hasComboInformation; + private readonly EditorBeatmap editorBeatmap; + + public ComboColourPalettePopover(IReadOnlyList comboColours, IHasComboInformation hasComboInformation, EditorBeatmap editorBeatmap) + { + this.comboColours = comboColours; + this.hasComboInformation = hasComboInformation; + this.editorBeatmap = editorBeatmap; + + AllowableAnchors = [Anchor.CentreRight]; + } + + [BackgroundDependencyLoader] + private void load() + { + Debug.Assert(comboColours.Count > 0); + var hitObject = hasComboInformation as HitObject; + Debug.Assert(hitObject != null); + + FillFlowContainer container; + + Child = container = new FillFlowContainer + { + Width = 230, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + }; + + int selectedColourIndex = comboIndexFor(hasComboInformation, comboColours); + + for (int i = 0; i < comboColours.Count; i++) + { + int index = i; + + if (getPreviousHitObjectWithCombo(editorBeatmap, hitObject) is IHasComboInformation previousHasCombo + && index == comboIndexFor(previousHasCombo, comboColours) + && !canReuseLastComboColour(editorBeatmap, hitObject)) + { + continue; + } + + container.Add(new OsuClickableContainer + { + Size = new Vector2(50), + Masking = true, + CornerRadius = 25, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = comboColours[index], + }, + selectedColourIndex == index + ? new SpriteIcon + { + Icon = FontAwesome.Solid.Check, + Size = new Vector2(24), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.ForegroundTextColourFor(comboColours[index]), + } + : Empty() + }, + Action = () => + { + int comboDifference = index - selectedColourIndex; + if (comboDifference == 0) + return; + + int newOffset = hasComboInformation.ComboOffset + comboDifference; + // `newOffset` must be positive to serialise correctly - this implements the true math "modulus" rather than the built-in "remainder" % op + // which can return negative results when the first operand is negative + newOffset -= (int)Math.Floor((double)newOffset / comboColours.Count) * comboColours.Count; + + hasComboInformation.ComboOffset = newOffset; + editorBeatmap.BeginChange(); + editorBeatmap.Update((HitObject)hasComboInformation); + editorBeatmap.EndChange(); + this.HidePopover(); + } + }); + } + } + + private static IHasComboInformation? getPreviousHitObjectWithCombo(EditorBeatmap editorBeatmap, HitObject hitObject) + => editorBeatmap.HitObjects.TakeWhile(ho => ho != hitObject).LastOrDefault() as IHasComboInformation; + + private static bool canReuseLastComboColour(EditorBeatmap editorBeatmap, HitObject hitObject) + { + double? closestBreakEnd = editorBeatmap.Breaks.Select(b => b.EndTime) + .Where(t => t <= hitObject.StartTime) + .OrderBy(t => t) + .LastOrDefault(); + + if (closestBreakEnd == null) + return false; + + return editorBeatmap.HitObjects.FirstOrDefault(ho => ho.StartTime >= closestBreakEnd) == hitObject; + } + } + + // compare `EditorBeatmapSkin.updateColours()` et al. for reasoning behind the off-by-one index rotation + private static int comboIndexFor(IHasComboInformation hasComboInformation, IReadOnlyCollection comboColours) + => (hasComboInformation.ComboIndexWithOffsets + comboColours.Count - 1) % comboColours.Count; + } +} diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs index 33eb2ac0b4..a9aa4b4227 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs @@ -1,23 +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 Humanizer; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; namespace osu.Game.Screens.Edit.Components.TernaryButtons { public partial class SampleBankTernaryButton : CompositeDrawable { - public readonly TernaryButton NormalButton; - public readonly TernaryButton AdditionsButton; + public string BankName { get; } + public Func? CreateIcon { get; init; } - public SampleBankTernaryButton(TernaryButton normalButton, TernaryButton additionsButton) + public readonly BindableWithCurrent NormalState = new BindableWithCurrent(); + public readonly BindableWithCurrent AdditionsState = new BindableWithCurrent(); + + public DrawableTernaryButton NormalButton { get; private set; } = null!; + public DrawableTernaryButton AdditionsButton { get; private set; } = null!; + + public SampleBankTernaryButton(string bankName) { - NormalButton = normalButton; - AdditionsButton = additionsButton; + BankName = bankName; } [BackgroundDependencyLoader] @@ -36,7 +45,12 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons AutoSizeAxes = Axes.Y, Width = 0.5f, Padding = new MarginPadding { Right = 1 }, - Child = new InlineDrawableTernaryButton(NormalButton), + Child = NormalButton = new InlineDrawableTernaryButton + { + Current = NormalState, + Description = BankName.Titleize(), + CreateIcon = CreateIcon, + }, }, new Container { @@ -46,18 +60,18 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons AutoSizeAxes = Axes.Y, Width = 0.5f, Padding = new MarginPadding { Left = 1 }, - Child = new InlineDrawableTernaryButton(AdditionsButton), + Child = AdditionsButton = new InlineDrawableTernaryButton + { + Current = AdditionsState, + Description = BankName.Titleize(), + CreateIcon = CreateIcon, + }, }, }; } private partial class InlineDrawableTernaryButton : DrawableTernaryButton { - public InlineDrawableTernaryButton(TernaryButton button) - : base(button) - { - } - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs deleted file mode 100644 index b7aaf517f5..0000000000 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.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.Bindables; -using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Edit.Components.TernaryButtons -{ - public class TernaryButton - { - public readonly Bindable Bindable; - - public readonly Bindable Enabled = new Bindable(true); - - public readonly string Description; - - /// - /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. - /// - public readonly Func? CreateIcon; - - public string Tooltip { get; set; } = string.Empty; - - public TernaryButton(Bindable bindable, string description, Func? createIcon = null) - { - Bindable = bindable; - Description = description; - CreateIcon = createIcon; - } - - public void Toggle() - { - switch (Bindable.Value) - { - case TernaryState.False: - case TernaryState.Indeterminate: - Bindable.Value = TernaryState.True; - break; - - case TernaryState.True: - Bindable.Value = TernaryState.False; - break; - } - } - } -} diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index 8f2a3d49ca..d17f9011f4 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -18,6 +18,7 @@ namespace osu.Game.Screens.Edit.Components public partial class TimeInfoContainer : BottomBarContainer { private OsuSpriteText bpm = null!; + private OsuSpriteText progress = null!; [Resolved] private EditorBeatmap editorBeatmap { get; set; } = null!; @@ -36,26 +37,44 @@ namespace osu.Game.Screens.Edit.Components bpm = new OsuSpriteText { Colour = colours.Orange1, + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-1, 0), + Position = new Vector2(0, 4), + Anchor = Anchor.CentreRight, + Origin = Anchor.TopRight, + }, + progress = new OsuSpriteText + { + Colour = colours.Purple1, + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold, fixedWidth: true), + Spacing = new Vector2(-1, 0), Anchor = Anchor.CentreLeft, - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), Position = new Vector2(2, 4), } }; } private double? lastBPM; + private double? lastProgress; protected override void Update() { base.Update(); double newBPM = editorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime).BPM; + double newProgress = (int)(editorClock.CurrentTime / editorClock.TrackLength * 100); if (lastBPM != newBPM) { lastBPM = newBPM; bpm.Text = @$"{newBPM:0} BPM"; } + + if (lastProgress != newProgress) + { + lastProgress = newProgress; + progress.Text = @$"{newProgress:0}%"; + } } private partial class TimestampControl : OsuClickableContainer diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs index ee44df8598..e856009817 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; @@ -91,7 +92,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts section = value; X = (float)value.StartTime; - Width = (float)value.Duration; + // Minimum width ensures that very short kiai sections still show a slither of colour. + Width = (float)Math.Max(200, value.Duration); } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index 21b3b38388..afe14de3ea 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -4,11 +4,9 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; -using osu.Game.Overlays; +using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts @@ -26,7 +24,14 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts [BackgroundDependencyLoader] private void load() { - Add(marker = new MarkerVisualisation()); + Add(marker = new CentreMarker + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + Width = 10, + TriangleHeightRatio = 0.5f + }); } protected override bool OnDragStart(DragStartEvent e) => true; @@ -68,44 +73,5 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { // block base call so we don't clear our marker (can be reused on beatmap change). } - - private partial class MarkerVisualisation : CompositeDrawable - { - public MarkerVisualisation() - { - Anchor = Anchor.CentreLeft; - Origin = Anchor.Centre; - RelativePositionAxes = Axes.X; - RelativeSizeAxes = Axes.Y; - AutoSizeAxes = Axes.X; - InternalChildren = new Drawable[] - { - new Triangle - { - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - Scale = new Vector2(1, -1), - Size = new Vector2(10, 5), - }, - new Triangle - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Size = new Vector2(10, 5), - }, - new Box - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Width = 1.4f, - EdgeSmoothness = new Vector2(1, 0) - } - }; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) => Colour = colours.Highlight1; - } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs index 67bb1ef500..72b58bcb5f 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Extensions; @@ -36,6 +37,10 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts : base(time) { Alpha = 0.8f; + + // Display as a small circle on the middle line as to not clash with other displays. + RelativeSizeAxes = Axes.None; + Height = Width = 5; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs index ee7e759ebc..bec9e275cb 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs @@ -3,8 +3,8 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -26,7 +26,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } = null!; - protected readonly IBindable Track = new Bindable(); + [Resolved] + private EditorClock editorClock { get; set; } = null!; private readonly Container content; @@ -35,22 +36,17 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts public TimelinePart(Container? content = null) { AddInternal(this.content = content ?? new Container { RelativeSizeAxes = Axes.Both }); - - beatmap.ValueChanged += _ => - { - updateRelativeChildSize(); - }; - - Track.ValueChanged += _ => updateRelativeChildSize(); } [BackgroundDependencyLoader] - private void load(IBindable beatmap, EditorClock clock) + private void load(IBindable beatmap) { this.beatmap.BindTo(beatmap); LoadBeatmap(EditorBeatmap); - Track.BindTo(clock.Track); + this.beatmap.ValueChanged += _ => updateRelativeChildSize(); + editorClock.TrackChanged += updateRelativeChildSize; + updateRelativeChildSize(); } private void updateRelativeChildSize() @@ -68,5 +64,13 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { content.Clear(); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorClock.IsNotNull()) + editorClock.TrackChanged -= updateRelativeChildSize; + } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index c01481e840..568137cce1 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -52,13 +52,6 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary }, } }, - new PreviewTimePart - { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Height = 0.4f, - }, new BreakPart { Anchor = Anchor.Centre, @@ -85,6 +78,12 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary RelativeSizeAxes = Axes.Both, Height = 0.4f }, + new PreviewTimePart + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, new MarkerPart { RelativeSizeAxes = Axes.Both }, }; } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs index 169e72fe3f..f5c0ed2382 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -14,6 +15,9 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary { public partial class TestGameplayButton : OsuButton { + [Resolved] + private OsuColour colours { get; set; } = null!; + protected override SpriteText CreateText() => new OsuSpriteText { Depth = -1, @@ -24,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary }; [BackgroundDependencyLoader] - private void load(OsuColour colours, OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider) { BackgroundColour = colours.Orange1; SpriteText.Colour = colourProvider.Background6; @@ -33,5 +37,18 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Text = EditorStrings.TestBeatmap; } + + protected override bool OnMouseDown(MouseDownEvent e) + { + Background.FadeColour(colours.Orange0, 500, Easing.OutQuint); + // don't call base in order to block scale animation + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + Background.FadeColour(colours.Orange1, 300, Easing.OutQuint); + // don't call base in order to block scale animation + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 43a2abe4c4..da145f0994 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -146,22 +146,11 @@ namespace osu.Game.Screens.Edit.Compose.Components } } }, - new Drawable[] - { - new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) - { - Padding = new MarginPadding { Horizontal = 15, Vertical = 2 }, - Text = "beat snap", - RelativeSizeAxes = Axes.X, - TextAnchor = Anchor.TopCentre, - }, - }, }, RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, 40), new Dimension(GridSizeMode.Absolute, 20), - new Dimension(GridSizeMode.Absolute, 15) } } }; @@ -178,6 +167,9 @@ namespace osu.Game.Screens.Edit.Compose.Components }, true); } + protected override bool OnMouseDown(MouseDownEvent e) => true; + protected override bool OnClick(ClickEvent e) => true; + private void cycleDivisorType(int direction) { int totalTypes = Enum.GetValues().Length; @@ -381,10 +373,11 @@ namespace osu.Game.Screens.Edit.Compose.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { - IconColour = Color4.Black; - HoverColour = colours.Gray7; + IconColour = colourProvider.Light3; + IconHoverColour = Color4.White; + HoverColour = colours.Gray6; FlashColour = colours.Gray9; } } @@ -398,6 +391,8 @@ namespace osu.Game.Screens.Edit.Compose.Components private readonly BindableBeatDivisor beatDivisor; + public override bool AcceptsFocus => false; + public TickSliderBar(BindableBeatDivisor beatDivisor) { CurrentNumber.BindTo(this.beatDivisor = beatDivisor); diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs index 766d5b5601..f1b7951999 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs @@ -185,9 +185,28 @@ namespace osu.Game.Screens.Edit.Compose.Components private void onDirectionChanged(ValueChangedEvent direction) { - Origin = Anchor = direction.NewValue == ScrollingDirection.Up - ? Anchor.TopLeft - : Anchor.BottomLeft; + switch (direction.NewValue) + { + case ScrollingDirection.Up: + Anchor = Anchor.TopLeft; + Origin = Anchor.CentreLeft; + break; + + case ScrollingDirection.Down: + Anchor = Anchor.BottomLeft; + Origin = Anchor.CentreLeft; + break; + + case ScrollingDirection.Left: + Anchor = Anchor.TopLeft; + Origin = Anchor.TopCentre; + break; + + case ScrollingDirection.Right: + Anchor = Anchor.TopRight; + Origin = Anchor.TopCentre; + break; + } bool isHorizontal = direction.NewValue == ScrollingDirection.Left || direction.NewValue == ScrollingDirection.Right; diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 4a321f4a81..d4c70d53df 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -43,9 +43,6 @@ namespace osu.Game.Screens.Edit.Compose.Components private readonly Dictionary> blueprintMap = new Dictionary>(); - [Resolved(canBeNull: true)] - private IPositionSnapProvider snapProvider { get; set; } - [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } @@ -118,19 +115,19 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnMouseDown(MouseDownEvent e) { - bool selectionPerformed = performMouseDownActions(e); + bool handled = performMouseDownActions(e); bool movementPossible = prepareSelectionMovement(e); - // check if selection has occurred - if (selectionPerformed) + if (SelectedItems.Any()) { - // only unmodified right click should show context menu + // if there is a selection and there are no modifiers pressed, don't block so the context menu still shows. bool shouldShowContextMenu = e.Button == MouseButton.Right && !e.ShiftPressed && !e.AltPressed && !e.SuperPressed; - - // stop propagation if not showing context menu return !shouldShowContextMenu; } + if (handled) + return true; + // even if a selection didn't occur, a drag event may still move the selection. return e.Button == MouseButton.Left && movementPossible; } @@ -169,6 +166,11 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override void OnMouseUp(MouseUpEvent e) { + // When an object is being dragged, ONLY a left MouseUpEvent should end the drag and finalize the changes caused by the drag. + // Otherwise, other mouse inputs while a drag is occurring will cause change transactions to lock up. + if (e.Button != MouseButton.Left) + return; + // Special case for when a drag happened instead of a click Schedule(() => { @@ -333,19 +335,19 @@ namespace osu.Game.Screens.Edit.Compose.Components protected void RemoveBlueprintFor(T item) { - if (!blueprintMap.Remove(item, out var blueprint)) + if (!blueprintMap.Remove(item, out var blueprintToRemove)) return; - blueprint.Deselect(); - blueprint.Selected -= OnBlueprintSelected; - blueprint.Deselected -= OnBlueprintDeselected; + blueprintToRemove.Deselect(); + blueprintToRemove.Selected -= OnBlueprintSelected; + blueprintToRemove.Deselected -= OnBlueprintDeselected; - SelectionBlueprints.Remove(blueprint, true); + SelectionBlueprints.Remove(blueprintToRemove, true); - if (movementBlueprints?.Contains(blueprint) == true) + if (movementBlueprints?.Any(m => m.blueprint == blueprintToRemove) == true) finishSelectionMovement(); - OnBlueprintRemoved(blueprint.Item); + OnBlueprintRemoved(blueprintToRemove.Item); } /// @@ -538,8 +540,7 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Movement - private Vector2[][] movementBlueprintsOriginalPositions; - private SelectionBlueprint[] movementBlueprints; + private (SelectionBlueprint blueprint, Vector2[] originalSnapPositions)[] movementBlueprints; /// /// Whether a blueprint is currently being dragged. @@ -572,8 +573,7 @@ namespace osu.Game.Screens.Edit.Compose.Components 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(); - movementBlueprintsOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSnapPoints).ToArray(); + movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).Select(b => (b, b.ScreenSpaceSnapPoints)).ToArray(); return true; } @@ -594,68 +594,10 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - Debug.Assert(movementBlueprintsOriginalPositions != null); - - Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; - - if (snapProvider != null) - { - for (int i = 0; i < movementBlueprints.Length; i++) - { - if (checkSnappingBlueprintToNearbyObjects(movementBlueprints[i], distanceTravelled, movementBlueprintsOriginalPositions[i])) - return true; - } - } - - // if no positional snapping could be performed, try unrestricted snapping from the earliest - // item in the selection. - - // The final movement position, relative to movementBlueprintOriginalPosition. - Vector2 movePosition = movementBlueprintsOriginalPositions.First().First() + distanceTravelled; - - // Retrieve a snapped position. - var result = snapProvider?.FindSnappedPositionAndTime(movePosition, ~SnapType.NearbyObjects); - - if (result == null) - { - return SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), movePosition - movementBlueprints.First().ScreenSpaceSelectionPoint)); - } - - return ApplySnapResult(movementBlueprints, result); + return TryMoveBlueprints(e, movementBlueprints); } - /// - /// Check for positional snap for given blueprint. - /// - /// The blueprint to check for snapping. - /// Distance travelled since start of dragging action. - /// The snap positions of blueprint before start of dragging action. - /// Whether an object to snap to was found. - private bool checkSnappingBlueprintToNearbyObjects(SelectionBlueprint blueprint, Vector2 distanceTravelled, Vector2[] originalPositions) - { - var currentPositions = blueprint.ScreenSpaceSnapPoints; - - for (int i = 0; i < originalPositions.Length; i++) - { - Vector2 originalPosition = originalPositions[i]; - var testPosition = originalPosition + distanceTravelled; - - var positionalResult = snapProvider.FindSnappedPositionAndTime(testPosition, SnapType.NearbyObjects); - - if (positionalResult.ScreenSpacePosition == testPosition) continue; - - var delta = positionalResult.ScreenSpacePosition - currentPositions[i]; - - // attempt to move the objects, and abort any time based snapping if we can. - if (SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprint, delta))) - return true; - } - - return false; - } - - protected virtual bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) => - SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprints.First(), result.ScreenSpacePosition - blueprints.First().ScreenSpaceSelectionPoint)); + protected abstract bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints); /// /// Finishes the current movement of selected blueprints. @@ -666,7 +608,6 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - movementBlueprintsOriginalPositions = null; movementBlueprints = null; return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index bd750dac76..8c7afd2aeb 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osuTK; using osuTK.Graphics; @@ -16,11 +16,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { public abstract partial class CircularDistanceSnapGrid : DistanceSnapGrid { - [Resolved] - private EditorClock editorClock { get; set; } = null!; - - protected CircularDistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) - : base(referenceObject, startPosition, startTime, endTime) + protected CircularDistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null, IHasSliderVelocity? sliderVelocitySource = null) + : base(startPosition, startTime, endTime, sliderVelocitySource) { } @@ -59,14 +56,14 @@ namespace osu.Game.Screens.Edit.Compose.Components // Picture the scenario where the user has just placed an object on a 1/2 snap, then changes to // 1/3 snap and expects to be able to place the next object on a valid 1/3 snap, regardless of the // fact that the 1/2 snap reference object is not valid for 1/3 snapping. - float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0, DistanceSnapTarget.End); + float offset = (float)(SnapProvider.FindSnappedDistance(0, StartTime, SliderVelocitySource) * DistanceSpacingMultiplier.Value); for (int i = 0; i < requiredCircles; i++) { const float thickness = 4; float diameter = (offset + (i + 1) * DistanceBetweenTicks + thickness / 2) * 2; - AddInternal(new Ring(ReferenceObject, GetColourForIndexFromPlacement(i)) + AddInternal(new Ring(StartTime, GetColourForIndexFromPlacement(i), SliderVelocitySource) { Position = StartPosition, Origin = Anchor.Centre, @@ -76,7 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - public override (Vector2 position, double time) GetSnappedPosition(Vector2 position) + public override (Vector2 position, double time) GetSnappedPosition(Vector2 position, double? fixedTime = null) { if (MaxIntervals == 0) return (StartPosition, StartTime); @@ -100,20 +97,20 @@ namespace osu.Game.Screens.Edit.Compose.Components if (travelLength < DistanceBetweenTicks) travelLength = DistanceBetweenTicks; - float snappedDistance = LimitedDistanceSnap.Value - ? SnapProvider.DurationToDistance(ReferenceObject, editorClock.CurrentTime - ReferenceObject.GetEndTime()) + float snappedDistance = fixedTime != null + ? SnapProvider.DurationToDistance(fixedTime.Value - StartTime, StartTime, SliderVelocitySource) // When interacting with the resolved snap provider, the distance spacing multiplier should first be removed // to allow for snapping at a non-multiplied ratio. - : SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier, DistanceSnapTarget.End); + : SnapProvider.FindSnappedDistance(travelLength / distanceSpacingMultiplier, StartTime, SliderVelocitySource); - double snappedTime = StartTime + SnapProvider.DistanceToDuration(ReferenceObject, snappedDistance); + double snappedTime = StartTime + SnapProvider.DistanceToDuration(snappedDistance, StartTime, SliderVelocitySource); if (snappedTime > LatestEndTime) { double tickLength = Beatmap.GetBeatLengthAtTime(StartTime); - snappedDistance = SnapProvider.DurationToDistance(ReferenceObject, MaxIntervals * tickLength); - snappedTime = StartTime + SnapProvider.DistanceToDuration(ReferenceObject, snappedDistance); + snappedDistance = SnapProvider.DurationToDistance(MaxIntervals * tickLength, StartTime, SliderVelocitySource); + snappedTime = StartTime + SnapProvider.DistanceToDuration(snappedDistance, StartTime, SliderVelocitySource); } // The multiplier can then be reapplied to the final position. @@ -130,13 +127,15 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved] private EditorClock? editorClock { get; set; } - private readonly HitObject referenceObject; + private readonly double startTime; + private readonly IHasSliderVelocity? sliderVelocitySource; private readonly Color4 baseColour; - public Ring(HitObject referenceObject, Color4 baseColour) + public Ring(double startTime, Color4 baseColour, IHasSliderVelocity? sliderVelocitySource) { - this.referenceObject = referenceObject; + this.startTime = startTime; + this.sliderVelocitySource = sliderVelocitySource; Colour = this.baseColour = baseColour; @@ -151,9 +150,9 @@ namespace osu.Game.Screens.Edit.Compose.Components return; float distanceSpacingMultiplier = (float)snapProvider.DistanceSpacingMultiplier.Value; - double timeFromReferencePoint = editorClock.CurrentTime - referenceObject.GetEndTime(); + double timeFromReferencePoint = editorClock.CurrentTime - startTime; - float distanceForCurrentTime = snapProvider.DurationToDistance(referenceObject, timeFromReferencePoint) + float distanceForCurrentTime = snapProvider.DurationToDistance(timeFromReferencePoint, startTime, sliderVelocitySource) * distanceSpacingMultiplier; float timeBasedAlpha = 1 - Math.Clamp(Math.Abs(distanceForCurrentTime - Size.X / 2) / 30, 0, 1); diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 0ffd1072cd..4414e963bf 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using Humanizer; @@ -26,14 +27,13 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Components.TernaryButtons; using osuTK; -using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// /// A blueprint container generally displayed as an overlay to a ruleset's playfield. /// - public partial class ComposeBlueprintContainer : EditorBlueprintContainer + public abstract partial class ComposeBlueprintContainer : EditorBlueprintContainer { private readonly Container placementBlueprintContainer; @@ -52,7 +52,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => editorScreen?.MainContent.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); - public ComposeBlueprintContainer(HitObjectComposer composer) + protected override IEnumerable> ApplySelectionOrder(IEnumerable> blueprints) => + base.ApplySelectionOrder(blueprints) + .OrderBy(b => Math.Min(Math.Abs(EditorClock.CurrentTime - b.Item.GetEndTime()), Math.Abs(EditorClock.CurrentTime - b.Item.StartTime))); + + protected ComposeBlueprintContainer(HitObjectComposer composer) : base(composer) { placementBlueprintContainer = new Container @@ -65,11 +69,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void load() { MainTernaryStates = CreateTernaryButtons().ToArray(); - SampleBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionBankStates).ToArray(); - SampleAdditionBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionAdditionBankStates).ToArray(); - - SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true); - SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true); + SampleBankTernaryStates = createSampleBankTernaryButtons().ToArray(); AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) { @@ -98,6 +98,9 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach (var kvp in SelectionHandler.SelectionAdditionBankStates) kvp.Value.BindValueChanged(_ => updatePlacementSamples()); + + SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true); + SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true); } protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) @@ -108,69 +111,6 @@ namespace osu.Game.Screens.Edit.Compose.Components blueprint.DrawableObject = drawableObject; } - private bool nudgeMovementActive; - - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.ControlPressed) - { - switch (e.Key) - { - case Key.Left: - nudgeSelection(new Vector2(-1, 0)); - return true; - - case Key.Right: - nudgeSelection(new Vector2(1, 0)); - return true; - - case Key.Up: - nudgeSelection(new Vector2(0, -1)); - return true; - - case Key.Down: - nudgeSelection(new Vector2(0, 1)); - return true; - } - } - - return false; - } - - protected override void OnKeyUp(KeyUpEvent e) - { - base.OnKeyUp(e); - - if (nudgeMovementActive && !e.ControlPressed) - { - Beatmap.EndChange(); - nudgeMovementActive = false; - } - } - - /// - /// Move the current selection spatially by the specified delta, in gamefield coordinates (ie. the same coordinates as the blueprints). - /// - /// - private void nudgeSelection(Vector2 delta) - { - if (!nudgeMovementActive) - { - nudgeMovementActive = true; - Beatmap.BeginChange(); - } - - var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); - - if (firstBlueprint == null) - return; - - // convert to game space coordinates - delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); - - SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); - } - private void updatePlacementNewCombo() { if (CurrentHitObjectPlacement?.HitObject is IHasComboInformation c) @@ -238,28 +178,40 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A collection of states which will be displayed to the user in the toolbox. /// - public TernaryButton[] MainTernaryStates { get; private set; } + public Drawable[] MainTernaryStates { get; private set; } - public TernaryButton[] SampleBankTernaryStates { get; private set; } - - public TernaryButton[] SampleAdditionBankTernaryStates { get; private set; } + public SampleBankTernaryButton[] SampleBankTernaryStates { get; private set; } /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() + protected virtual IEnumerable CreateTernaryButtons() { //TODO: this should only be enabled (visible?) for rulesets that provide combo-supporting HitObjects. - yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }); + yield return new NewComboTernaryButton { Current = NewCombo }; foreach (var kvp in SelectionHandler.SelectionSampleStates) - yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => GetIconForSample(kvp.Key)); + { + yield return new DrawableTernaryButton + { + Current = kvp.Value, + Description = kvp.Key.Replace(@"hit", string.Empty).Titleize(), + CreateIcon = () => GetIconForSample(kvp.Key), + }; + } } - private IEnumerable createSampleBankTernaryButtons(Dictionary> sampleBankStates) + private IEnumerable createSampleBankTernaryButtons() { - foreach (var kvp in sampleBankStates) - yield return new TernaryButton(kvp.Value, kvp.Key.Titleize(), () => getIconForBank(kvp.Key)); + foreach (string bankName in HitSampleInfo.ALL_BANKS.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)) + { + yield return new SampleBankTernaryButton(bankName) + { + NormalState = { Current = SelectionHandler.SelectionBankStates[bankName], }, + AdditionsState = { Current = SelectionHandler.SelectionAdditionBankStates[bankName], }, + CreateIcon = () => getIconForBank(bankName) + }; + } } private Drawable getIconForBank(string sampleName) @@ -295,19 +247,19 @@ namespace osu.Game.Screens.Edit.Compose.Components { bool enabled = SelectionHandler.AutoSelectionBankEnabled.Value; - var autoBankButton = SampleBankTernaryStates.Single(t => t.Bindable == SelectionHandler.SelectionBankStates[EditorSelectionHandler.HIT_BANK_AUTO]); - autoBankButton.Enabled.Value = enabled; - autoBankButton.Tooltip = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty; + var autoBankButton = SampleBankTernaryStates.Single(t => t.BankName == EditorSelectionHandler.HIT_BANK_AUTO); + autoBankButton.NormalButton.Enabled.Value = enabled; + autoBankButton.NormalButton.TooltipText = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty; } private void updateAdditionBankTernaryButtonTooltips() { bool enabled = SelectionHandler.SelectionAdditionBanksEnabled.Value; - foreach (var ternaryButton in SampleAdditionBankTernaryStates) + foreach (var ternaryButton in SampleBankTernaryStates) { - ternaryButton.Enabled.Value = enabled; - ternaryButton.Tooltip = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty; + ternaryButton.AdditionsButton.Enabled.Value = enabled; + ternaryButton.AdditionsButton.TooltipText = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty; } } @@ -327,12 +279,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updatePlacementTimeAndPosition() { - var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position, CurrentPlacement.SnapType); - - // if no time was found from positional snapping, we should still quantize to the beat. - snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); - - CurrentPlacement.UpdateTimeAndPosition(snapResult); + CurrentPlacement.UpdateTimeAndPosition(InputManager.CurrentState.Mouse.Position, Beatmap.SnapTime(EditorClock.CurrentTime, null)); } #endregion @@ -440,6 +387,8 @@ namespace osu.Game.Screens.Edit.Compose.Components currentTool = value; + SelectionHandler.RightClickAlwaysQuickDeletes = currentTool is not SelectTool; + // As per stable editor, when changing tools, we should forcefully commit any pending placement. CommitIfPlacementActive(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 7003d632ca..8322c67def 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -4,16 +4,17 @@ #nullable disable using System; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; -using osu.Game.Configuration; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osuTK; using osuTK.Graphics; @@ -49,6 +50,9 @@ namespace osu.Game.Screens.Edit.Compose.Components protected readonly double? LatestEndTime; + [CanBeNull] + protected readonly IHasSliderVelocity SliderVelocitySource; + [Resolved] protected OsuColour Colours { get; private set; } @@ -61,33 +65,19 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved] private BindableBeatDivisor beatDivisor { get; set; } - /// - /// When enabled, distance snap should only snap to the current time (as per the editor clock). - /// This is to emulate stable behaviour. - /// - protected Bindable LimitedDistanceSnap { get; private set; } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - LimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); - } - private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); - protected readonly HitObject ReferenceObject; - /// /// Creates a new . /// - /// A reference object to gather relevant difficulty values from. /// The position at which the grid should start. The first tick is located one distance spacing length away from this point. /// The snapping time at . /// The time at which the snapping grid should end. If null, the grid will continue until the bounds of the screen are exceeded. - protected DistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) + /// The reference object with slider velocity to include in the calculations for distance snapping. + protected DistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null, [CanBeNull] IHasSliderVelocity sliderVelocitySource = null) { - ReferenceObject = referenceObject; LatestEndTime = endTime; + SliderVelocitySource = sliderVelocitySource; StartPosition = startPosition; StartTime = startTime; @@ -110,14 +100,14 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updateSpacing() { float distanceSpacingMultiplier = (float)DistanceSpacingMultiplier.Value; - float beatSnapDistance = SnapProvider.GetBeatSnapDistanceAt(ReferenceObject, false); + float beatSnapDistance = SnapProvider.GetBeatSnapDistance(SliderVelocitySource); DistanceBetweenTicks = beatSnapDistance * distanceSpacingMultiplier; if (LatestEndTime == null) MaxIntervals = int.MaxValue; else - MaxIntervals = (int)((LatestEndTime.Value - StartTime) / SnapProvider.DistanceToDuration(ReferenceObject, beatSnapDistance)); + MaxIntervals = (int)((LatestEndTime.Value - StartTime) / SnapProvider.DistanceToDuration(beatSnapDistance, StartTime, SliderVelocitySource)); gridCache.Invalidate(); } @@ -143,8 +133,12 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Snaps a position to this grid. /// /// The original position in coordinate space local to this . + /// + /// Whether the snap operation should be temporally constrained to a particular time instant, + /// thus fixing the possible positions to a set distance relative from the . + /// /// A tuple containing the snapped position in coordinate space local to this and the respective time value. - public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position); + public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position, double? fixedTime = null); /// /// Retrieves the applicable colour for a beat index. @@ -155,7 +149,18 @@ namespace osu.Game.Screens.Edit.Compose.Components { var timingPoint = Beatmap.ControlPointInfo.TimingPointAt(StartTime); double beatLength = timingPoint.BeatLength / beatDivisor.Value; - int beatIndex = (int)Math.Floor((StartTime - timingPoint.Time) / beatLength); + double fractionalBeatIndex = (StartTime - timingPoint.Time) / beatLength; + int beatIndex = (int)Math.Round(fractionalBeatIndex); + // `fractionalBeatIndex` could differ from `beatIndex` for two reasons: + // - rounding errors (which can be exacerbated by timing point start times being truncated by/for stable), + // - `StartTime` is not snapped to the beat. + // in case 1, we want rounding to occur to prevent an off-by-one, + // as `StartTime` *is* quantised to the beat. but it just doesn't look like it because floats do float things. + // in case 2, we want *flooring* to occur, to prevent a possible off-by-one + // because of the rounding snapping forward by a chunk of time significantly too high to be considered a rounding error. + // the tolerance margin chosen here is arbitrary and can be adjusted if more cases of this are found. + if (Precision.DefinitelyBigger(beatIndex, fractionalBeatIndex, 0.01)) + beatIndex = (int)Math.Floor(fractionalBeatIndex); var colour = BindableBeatDivisor.GetColourFor(BindableBeatDivisor.GetDivisorForBeatIndex(beatIndex + placementIndex + 1, beatDivisor.Value), Colours); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index 7b046251e0..e8de1eaad9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -3,7 +3,6 @@ #nullable disable -using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; @@ -17,7 +16,7 @@ using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Screens.Edit.Compose.Components { - public partial class EditorBlueprintContainer : BlueprintContainer + public abstract partial class EditorBlueprintContainer : BlueprintContainer { [Resolved] protected EditorClock EditorClock { get; private set; } @@ -73,27 +72,16 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override IEnumerable> SortForMovement(IReadOnlyList> blueprints) => blueprints.OrderBy(b => b.Item.StartTime); - protected override bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) + protected void ApplySnapResultTime(SnapResult result, double referenceTime) { - if (!base.ApplySnapResult(blueprints, result)) - return false; + if (!result.Time.HasValue) + return; - if (result.Time.HasValue) - { - // Apply the start time at the newly snapped-to position - double offset = result.Time.Value - blueprints.First().Item.StartTime; + // Apply the start time at the newly snapped-to position + double offset = result.Time.Value - referenceTime; - if (offset != 0) - { - Beatmap.PerformOnSelection(obj => - { - obj.StartTime += offset; - Beatmap.Update(obj); - }); - } - } - - return true; + if (offset != 0) + Beatmap.PerformOnSelection(obj => obj.StartTime += offset); } protected override void AddBlueprintFor(HitObject item) @@ -131,10 +119,6 @@ namespace osu.Game.Screens.Edit.Compose.Components return true; } - protected override IEnumerable> ApplySelectionOrder(IEnumerable> blueprints) => - base.ApplySelectionOrder(blueprints) - .OrderBy(b => Math.Min(Math.Abs(EditorClock.CurrentTime - b.Item.GetEndTime()), Math.Abs(EditorClock.CurrentTime - b.Item.StartTime))); - protected override SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; protected override SelectionHandler CreateSelectionHandler() => new EditorSelectionHandler(); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 78cee2c1cf..a258016da5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -10,16 +10,23 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { public partial class EditorSelectionHandler : SelectionHandler { + /// + /// Whether right click should delete even when shift is not held. + /// + public bool RightClickAlwaysQuickDeletes { get; set; } + /// /// A special bank name that is only used in the editor UI. /// When selected and in placement mode, the bank of the last hit object will always be used. @@ -40,6 +47,14 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectedItems.CollectionChanged += onSelectedItemsChanged; } + protected override bool ShouldQuickDelete(MouseButtonEvent e) + { + if (RightClickAlwaysQuickDeletes && e.Button == MouseButton.Right) + return true; + + return base.ShouldQuickDelete(e); + } + protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); #region Selection State @@ -79,7 +94,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// private void createStateBindables() { - foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + foreach (string bankName in HitSampleInfo.ALL_BANKS.Prepend(HIT_BANK_AUTO)) { var bindable = new Bindable { @@ -143,7 +158,7 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionBankStates[bankName] = bindable; } - foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + foreach (string bankName in HitSampleInfo.ALL_BANKS.Prepend(HIT_BANK_AUTO)) { var bindable = new Bindable { @@ -216,7 +231,7 @@ namespace osu.Game.Screens.Edit.Compose.Components resetTernaryStates(); - foreach (string sampleName in HitSampleInfo.AllAdditions) + foreach (string sampleName in HitSampleInfo.ALL_ADDITIONS) { var bindable = new Bindable { @@ -266,6 +281,8 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionAdditionBanksEnabled.Value = true; SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; + foreach (var (_, sampleState) in SelectionSampleStates) + sampleState.Value = TernaryState.False; } /// @@ -293,14 +310,15 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach ((string bankName, var bindable) in SelectionAdditionBankStates) { - bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), + h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); } } private void onSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) { // Reset the ternary states when the selection is cleared. - if (e.OldStartingIndex >= 0 && e.NewStartingIndex < 0) + if (SelectedItems.Count == 0) Scheduler.AddOnce(resetTernaryStates); else Scheduler.AddOnce(UpdateTernaryStates); @@ -355,8 +373,6 @@ namespace osu.Game.Screens.Edit.Compose.Components for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); } - - EditorBeatmap.Update(h); }); } @@ -380,18 +396,23 @@ namespace osu.Game.Screens.Edit.Compose.Components return; string normalBank = h.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; - h.Samples = h.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + h.Samples = h.Samples.Select(s => + s.Name != HitSampleInfo.HIT_NORMAL + ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) + : s) + .ToList(); if (h is IHasRepeats hasRepeats) { for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) { normalBank = hasRepeats.NodeSamples[i].FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; - hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => + s.Name != HitSampleInfo.HIT_NORMAL + ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) + : s).ToList(); } } - - EditorBeatmap.Update(h); }); } @@ -439,8 +460,6 @@ namespace osu.Game.Screens.Edit.Compose.Components node.Add(hitSample); } } - - EditorBeatmap.Update(h); }); } @@ -462,8 +481,6 @@ namespace osu.Game.Screens.Edit.Compose.Components for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Where(s => s.Name != sampleName).ToList(); } - - EditorBeatmap.Update(h); }); } @@ -484,7 +501,6 @@ namespace osu.Game.Screens.Edit.Compose.Components if (comboInfo == null || comboInfo.NewCombo == state) return; comboInfo.NewCombo = state; - EditorBeatmap.Update(h); }); } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index 03d600bfa2..9b679a1344 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -127,7 +127,9 @@ namespace osu.Game.Screens.Edit.Compose.Components private void applyRotation(bool shouldSnap) { float newRotation = shouldSnap ? snap(rawCumulativeRotation, snap_step) : MathF.Round(rawCumulativeRotation); - newRotation = (newRotation - 180) % 360 + 180; + newRotation = ((newRotation + 360 + 180) % 360) - 180; + if (MathF.Abs(newRotation) == 180) + newRotation = 180; cumulativeRotation.Value = newRotation; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 39fff169b7..758b712fef 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -151,14 +151,6 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public virtual SelectionRotationHandler CreateRotationHandler() => new SelectionRotationHandler(); - /// - /// Handles the selected items being scaled. - /// - /// The delta scale to apply, in local coordinates. - /// The point of reference where the scale is originating from. - /// Whether any items could be scaled. - public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; - /// /// Creates the handler to use for scale operations. /// @@ -263,15 +255,17 @@ namespace osu.Game.Screens.Edit.Compose.Components selectedBlueprints.Remove(blueprint); } + protected virtual bool ShouldQuickDelete(MouseButtonEvent e) => e.Button == MouseButton.Middle || (e.ShiftPressed && e.Button == MouseButton.Right); + /// /// Handle a blueprint requesting selection. /// /// The blueprint. /// The mouse event responsible for selection. - /// Whether a selection was performed. + /// Whether an action was performed. internal virtual bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { - if (e.Button == MouseButton.Middle || (e.ShiftPressed && e.Button == MouseButton.Right)) + if (ShouldQuickDelete(e)) { handleQuickDeletion(blueprint); return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs index af3b3d6489..6cd2428b8a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs @@ -30,6 +30,8 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Implementation-defined origin point to rotate around when no explicit origin is provided. /// This field is only assigned during a rotation operation. + /// + /// Coordinates are in local space for this container. /// public Vector2? DefaultOrigin { get; protected set; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs index c63dfdfb55..145049e1dd 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs @@ -4,7 +4,10 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Game.Overlays; using osuTK; @@ -12,47 +15,118 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public partial class CentreMarker : CompositeDrawable { - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) + public float TriangleHeightRatio { - const float triangle_width = 8; - const float bar_width = 2f; + get => triangles.TriangleHeightRatio; + set => triangles.TriangleHeightRatio = value; + } + private readonly VerticalTriangles triangles; + + public CentreMarker() + { RelativeSizeAxes = Axes.Y; - - Anchor = Anchor.TopCentre; - Origin = Anchor.TopCentre; - - Size = new Vector2(triangle_width, 1); - + Masking = true; InternalChildren = new Drawable[] { - new Circle + new Box { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, - Width = bar_width, - Colour = colours.Colour2, + Width = 1.4f, + EdgeSmoothness = new Vector2(1, 0) }, - new Triangle + triangles = new VerticalTriangles { - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - Size = new Vector2(triangle_width, triangle_width * 0.8f), - Scale = new Vector2(1, -1), - EdgeSmoothness = new Vector2(1, 0), - Colour = colours.Colour2, - }, - new Triangle - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Size = new Vector2(triangle_width, triangle_width * 0.8f), - Scale = new Vector2(1, 1), - Colour = colours.Colour2, - }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + EdgeSmoothness = Vector2.One + } }; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) => Colour = colours.Highlight1; + + /// + /// Triangles drawn at the top and bottom of . + /// + /// + /// Since framework-side triangles don't support antialiasing we are using custom implementation involving rotated smoothened boxes to avoid + /// mismatch in antialiasing between top and bottom triangles when drawable moves across the screen. + /// To "trim" boxes we must enable masking at the top level. + /// + private partial class VerticalTriangles : Sprite + { + private float triangleHeightRatio = 1f; + + public float TriangleHeightRatio + { + get => triangleHeightRatio; + set + { + triangleHeightRatio = value; + Invalidate(Invalidation.DrawNode); + } + } + + [BackgroundDependencyLoader] + private void load(IRenderer renderer) + { + Texture = renderer.WhitePixel; + } + + protected override DrawNode CreateDrawNode() => new VerticalTrianglesDrawNode(this); + + private class VerticalTrianglesDrawNode : SpriteDrawNode + { + public new VerticalTriangles Source => (VerticalTriangles)base.Source; + + public VerticalTrianglesDrawNode(VerticalTriangles source) + : base(source) + { + } + + private float triangleScreenSpaceHeight; + + public override void ApplyState() + { + base.ApplyState(); + + triangleScreenSpaceHeight = ScreenSpaceDrawQuad.Width * Source.TriangleHeightRatio; + } + + protected override void Blit(IRenderer renderer) + { + if (triangleScreenSpaceHeight == 0 || DrawRectangle.Width == 0 || DrawRectangle.Height == 0) + return; + + Vector2 inflation = new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / (DrawRectangle.Width * Source.TriangleHeightRatio)); + + Quad topTriangle = new Quad + ( + ScreenSpaceDrawQuad.TopLeft, + ScreenSpaceDrawQuad.TopLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, -triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.TopLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.TopRight + ); + + Quad bottomTriangle = new Quad + ( + ScreenSpaceDrawQuad.BottomLeft, + ScreenSpaceDrawQuad.BottomLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, -triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.BottomLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.BottomRight + ); + + renderer.DrawQuad(Texture, topTriangle, DrawColourInfo.Colour, inflationPercentage: inflation); + renderer.DrawQuad(Texture, bottomTriangle, DrawColourInfo.Colour, inflationPercentage: inflation); + } + + protected override bool CanDrawOpaqueInterior => false; + } + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index c3a56c8df9..cdd2f52dab 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -16,7 +16,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; -using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -57,9 +56,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected virtual double GetTime() => HitObject is IHasRepeats r ? HitObject.StartTime + r.Duration / r.SpanCount() / 2 : HitObject.StartTime; [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load() { - HitObject.DefaultsApplied += _ => updateText(); Label.AllowMultiline = false; LabelContainer.AutoSizeAxes = Axes.None; updateText(); @@ -74,6 +72,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.LoadComplete(); + HitObject.DefaultsApplied += onDefaultsApplied; + if (timelineBlueprintContainer != null) contracted.BindTo(timelineBlueprintContainer.SamplePointContracted); @@ -96,12 +96,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline FinishTransforms(); } + private void onDefaultsApplied(HitObject hitObject) + { + updateText(); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (editor != null) editor.ShowSampleEditPopoverRequested -= onShowSampleEditPopoverRequested; + + HitObject.DefaultsApplied -= onDefaultsApplied; } private void onShowSampleEditPopoverRequested(double time) @@ -300,7 +307,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline createStateBindables(); updateTernaryStates(); - togglesCollection.AddRange(createTernaryButtons().Select(b => new DrawableTernaryButton(b) { RelativeSizeAxes = Axes.None, Size = new Vector2(40, 40) })); + togglesCollection.AddRange(createTernaryButtons()); } private string? getCommonBank() => allRelevantSamples.Select(h => GetBankValue(h.samples)).Distinct().Count() == 1 @@ -409,7 +416,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void createStateBindables() { - foreach (string sampleName in HitSampleInfo.AllAdditions) + foreach (string sampleName in HitSampleInfo.ALL_ADDITIONS) { var bindable = new Bindable { @@ -433,7 +440,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline selectionSampleStates[sampleName] = bindable; } - banks.AddRange(HitSampleInfo.AllBanks.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)); + banks.AddRange(HitSampleInfo.ALL_BANKS.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)); } private void updateTernaryStates() @@ -444,10 +451,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - private IEnumerable createTernaryButtons() + private IEnumerable createTernaryButtons() { foreach ((string sampleName, var bindable) in selectionSampleStates) - yield return new TernaryButton(bindable, string.Empty, () => ComposeBlueprintContainer.GetIconForSample(sampleName)); + { + yield return new DrawableTernaryButton + { + Current = bindable, + Description = string.Empty, + CreateIcon = () => ComposeBlueprintContainer.GetIconForSample(sampleName), + RelativeSizeAxes = Axes.None, + Size = new Vector2(40, 40), + }; + } } private void addHitSample(string sampleName) @@ -516,7 +532,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (item is not DrawableTernaryButton button) return base.OnKeyDown(e); - button.Button.Toggle(); + button.Toggle(); } return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 66621afa21..cbafea7600 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -3,9 +3,9 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; @@ -22,7 +22,7 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { [Cached] - public partial class Timeline : ZoomableScrollContainer, IPositionSnapProvider + public partial class Timeline : ZoomableScrollContainer { private const float timeline_height = 80; @@ -49,10 +49,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private EditorBeatmap editorBeatmap { get; set; } = null!; + [Resolved] + private IBindable beatmap { get; set; } = null!; + /// /// The timeline's scroll position in the last frame. /// - private float lastScrollPosition; + private double lastScrollPosition; /// /// The track time in the last frame. @@ -86,8 +89,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private double trackLengthForZoom; - private readonly IBindable track = new Bindable(); - public Timeline(Drawable userContent) { this.userContent = userContent; @@ -101,12 +102,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } [BackgroundDependencyLoader] - private void load(IBindable beatmap, OsuColour colours, OverlayColourProvider colourProvider, OsuConfigManager config) + private void load(OsuColour colours, OverlayColourProvider colourProvider, OsuConfigManager config) { CentreMarker centreMarker; // We don't want the centre marker to scroll - AddInternal(centreMarker = new CentreMarker()); + AddInternal(centreMarker = new CentreMarker + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 8, + TriangleHeightRatio = 0.8f, + Colour = colourProvider.Colour2 + }); AddRange(new Drawable[] { @@ -150,16 +158,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline controlPointsVisible = config.GetBindable(OsuSetting.EditorTimelineShowTimingChanges); ticksVisible = config.GetBindable(OsuSetting.EditorTimelineShowTicks); - track.BindTo(editorClock.Track); - track.BindValueChanged(_ => - { - waveform.Waveform = beatmap.Value.Waveform; - Scheduler.AddOnce(applyVisualOffset, beatmap); - }, true); + editorClock.TrackChanged += updateWaveform; + updateWaveform(); Zoom = (float)(defaultTimelineZoom * editorBeatmap.TimelineZoom); } + private void updateWaveform() + { + waveform.Waveform = beatmap.Value.Waveform; + Scheduler.AddOnce(applyVisualOffset, beatmap); + } + private void applyVisualOffset(IBindable beatmap) { waveform.RelativePositionAxes = Axes.X; @@ -319,7 +329,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// public double VisibleRange => editorClock.TrackLength / Zoom; - public double TimeAtPosition(float x) + public double TimeAtPosition(double x) { return x / Content.DrawWidth * editorClock.TrackLength; } @@ -329,10 +339,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return (float)(time / editorClock.TrackLength * Content.DrawWidth); } - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) { double time = TimeAtPosition(Content.ToLocalSpace(screenSpacePosition).X); return new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(time)); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorClock.IsNotNull()) + editorClock.TrackChanged -= updateWaveform; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index a4083f58b6..c149a8f73a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -16,7 +16,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -89,7 +91,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { placementBlueprint = CreateBlueprintFor(obj.NewValue).AsNonNull(); - placementBlueprint.Colour = OsuColour.Gray(0.9f); + // just to show the border. using the selection state doesn't seem to backfire. + // if it does then we'll probably want to just make `new` object above rather than rely on `CreateBlueprintFor`. + placementBlueprint.State = SelectionState.Selected; // TODO: this is out of order, causing incorrect stacking height. SelectionBlueprints.Add(placementBlueprint); @@ -106,6 +110,23 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return base.OnDragStart(e); } + protected override bool TryMoveBlueprints(DragEvent e, IList<(SelectionBlueprint blueprint, Vector2[] originalSnapPositions)> blueprints) + { + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // The final movement position, relative to movementBlueprintOriginalPosition. + Vector2 movePosition = blueprints.First().originalSnapPositions.First() + distanceTravelled; + + // Retrieve a snapped position. + var result = timeline?.FindSnappedPositionAndTime(movePosition) ?? new SnapResult(movePosition, null); + + var referenceBlueprint = blueprints.First().blueprint; + bool moved = SelectionHandler.HandleMovement(new MoveSelectionEvent(referenceBlueprint, result.ScreenSpacePosition - referenceBlueprint.ScreenSpaceSelectionPoint)); + if (moved) + ApplySnapResultTime(result, referenceBlueprint.Item.StartTime); + return moved; + } + private float dragTimeAccumulated; protected override void Update() @@ -131,7 +152,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateSamplePointContractedState() { - const double minimum_gap = 28; + const double absolute_minimum_gap = 31; // assumes single letter bank name for default banks + double minimumGap = absolute_minimum_gap; if (timeline == null || editorClock == null) return; @@ -153,9 +175,32 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (hitObject.GetEndTime() < editorClock.CurrentTime - timeline.VisibleRange / 2) break; + for (int i = 0; i < hitObject.Samples.Count; i++) + { + var sample = hitObject.Samples[i]; + + if (!HitSampleInfo.ALL_BANKS.Contains(sample.Bank)) + minimumGap = Math.Max(minimumGap, absolute_minimum_gap + sample.Bank.Length * 3); + } + if (hitObject is IHasRepeats hasRepeats) + { smallestTimeGap = Math.Min(smallestTimeGap, hasRepeats.Duration / hasRepeats.SpanCount() / 2); + for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) + { + var node = hasRepeats.NodeSamples[i]; + + for (int j = 0; j < node.Count; j++) + { + var sample = node[j]; + + if (!HitSampleInfo.ALL_BANKS.Contains(sample.Bank)) + minimumGap = Math.Max(minimumGap, absolute_minimum_gap + sample.Bank.Length * 3); + } + } + } + double gap = lastTime - hitObject.GetEndTime(); // If the gap is less than 1ms, we can assume that the objects are stacked on top of each other @@ -167,7 +212,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } double smallestAbsoluteGap = ((TimelineSelectionBlueprintContainer)SelectionBlueprints).ContentRelativeToAbsoluteFactor.X * smallestTimeGap; - SamplePointContracted.Value = smallestAbsoluteGap < minimum_gap; + SamplePointContracted.Value = smallestAbsoluteGap < minimumGap; } private readonly Stack currentConcurrentObjects = new Stack(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 6c0d5af247..f60d1b023b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -441,7 +441,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline double lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1); int proposedCount = Math.Max(0, (int)Math.Round(proposedDuration / lengthOfOneRepeat) - 1); - if (proposedCount == repeatHitObject.RepeatCount || Precision.AlmostEquals(lengthOfOneRepeat, 0)) + if (proposedCount == repeatHitObject.RepeatCount || Precision.AlmostEquals(lengthOfOneRepeat, 0, 1)) return; repeatHitObject.RepeatCount = proposedCount; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs index 66d0df9e18..faefdee096 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -31,9 +32,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private BindableBeatDivisor beatDivisor { get; set; } = null!; - [Resolved] - private IEditorChangeHandler? changeHandler { get; set; } - [Resolved] private OsuColour colours { get; set; } = null!; @@ -51,9 +49,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { beatDivisor.BindValueChanged(_ => invalidateTicks()); - if (changeHandler != null) - // currently this is the best way to handle any kind of timing changes. - changeHandler.OnStateChange += invalidateTicks; + beatmap.ControlPointInfo.ControlPointsChanged += invalidateTicks; configManager.BindWith(OsuSetting.EditorTimelineShowTimingChanges, showTimingChanges); showTimingChanges.BindValueChanged(_ => invalidateTicks()); @@ -194,8 +190,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.Dispose(isDisposing); - if (changeHandler != null) - changeHandler.OnStateChange -= invalidateTicks; + if (beatmap.IsNotNull()) + beatmap.ControlPointInfo.ControlPointsChanged -= invalidateTicks; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 31a0936eb4..b483f23d1d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -141,7 +141,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (e.AltPressed) { // zoom when holding alt. - AdjustZoomRelatively(e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); + AdjustZoomRelatively(e.ScrollDelta.Y); return true; } @@ -182,7 +182,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } private void transformZoomTo(float newZoom, float focusPoint, double duration = 0, Easing easing = Easing.None) - => this.TransformTo(this.PopulateTransform(new TransformZoom(focusPoint, zoomedContent.DrawWidth, Current), newZoom, duration, easing)); + => this.TransformTo(this.PopulateTransform(new TransformZoom(focusPoint, zoomedContent.DrawWidth, (float)Current), newZoom, duration, easing)); /// /// Invoked when has changed. diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index f7e523db25..195625dcde 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -31,6 +31,9 @@ namespace osu.Game.Screens.Edit.Compose [Resolved] private IGameplaySettings globalGameplaySettings { get; set; } + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } + private Bindable clipboard { get; set; } private HitObjectComposer composer; @@ -150,7 +153,7 @@ namespace osu.Game.Screens.Edit.Compose Debug.Assert(objects.Any()); - double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime); + double timeOffset = beatSnapProvider.SnapTime(clock.CurrentTime) - objects.Min(o => o.StartTime); foreach (var h in objects) h.StartTime += timeOffset; diff --git a/osu.Game/Screens/Edit/DiscardUnsavedChangesDialog.cs b/osu.Game/Screens/Edit/DiscardUnsavedChangesDialog.cs new file mode 100644 index 0000000000..1867b48830 --- /dev/null +++ b/osu.Game/Screens/Edit/DiscardUnsavedChangesDialog.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 osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public partial class DiscardUnsavedChangesDialog : PopupDialog + { + public DiscardUnsavedChangesDialog(Action exit) + { + HeaderText = EditorDialogsStrings.DiscardUnsavedChangesDialogHeader; + + Icon = FontAwesome.Solid.Trash; + + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = EditorDialogsStrings.ForgetAllChanges, + Action = exit + }, + new PopupDialogCancelButton + { + Text = EditorDialogsStrings.ContinueEditing, + }, + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index a022ca5435..05f74c8514 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -32,6 +32,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -45,16 +46,17 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Design; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; +using osu.Game.Screens.Edit.Submission; using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Verify; using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.Play; using osu.Game.Users; using osuTK.Input; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; @@ -63,7 +65,7 @@ namespace osu.Game.Screens.Edit { [Cached(typeof(IBeatSnapProvider))] [Cached] - public partial class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider + public partial class Editor : OsuScreen, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider { /// /// An offset applied to waveform visuals to align them with expectations. @@ -111,6 +113,10 @@ namespace osu.Game.Screens.Edit [Resolved(canBeNull: true)] private INotificationOverlay notifications { get; set; } + [Resolved(canBeNull: true)] + [CanBeNull] + private LoginOverlay loginOverlay { get; set; } + [Resolved] private RealmAccess realm { get; set; } @@ -158,6 +164,7 @@ namespace osu.Game.Screens.Edit private bool switchingDifficulty; private string lastSavedHash; + private EditorMenuItem discardChangesMenuItem; private ScreenContainer screenContainer; @@ -210,6 +217,7 @@ namespace osu.Game.Screens.Edit private OnScreenDisplay onScreenDisplay { get; set; } private Bindable editorBackgroundDim; + private Bindable editorShowStoryboard; private Bindable editorHitMarkers; private Bindable editorAutoSeekOnPlacement; private Bindable editorLimitedDistanceSnap; @@ -316,10 +324,14 @@ namespace osu.Game.Screens.Edit workingBeatmapUpdated = true; }); + var bookmarkController = new BookmarkController(); + AddInternal(bookmarkController); + OsuMenuItem undoMenuItem; OsuMenuItem redoMenuItem; editorBackgroundDim = config.GetBindable(OsuSetting.EditorDim); + editorShowStoryboard = config.GetBindable(OsuSetting.EditorShowStoryboard); editorHitMarkers = config.GetBindable(OsuSetting.EditorShowHitMarkers); editorAutoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); editorLimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); @@ -328,6 +340,18 @@ namespace osu.Game.Screens.Edit editorTimelineShowTicks = config.GetBindable(OsuSetting.EditorTimelineShowTicks); editorContractSidebars = config.GetBindable(OsuSetting.EditorContractSidebars); + // These two settings don't work together. Make them mutually exclusive to let the user know. + editorAutoSeekOnPlacement.BindValueChanged(enabled => + { + if (enabled.NewValue) + editorLimitedDistanceSnap.Value = false; + }); + editorLimitedDistanceSnap.BindValueChanged(enabled => + { + if (enabled.NewValue) + editorAutoSeekOnPlacement.Value = false; + }); + AddInternal(new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, @@ -398,7 +422,13 @@ namespace osu.Game.Screens.Edit }, ] }, + new OsuMenuItemSpacer(), new BackgroundDimMenuItem(editorBackgroundDim), + new ToggleMenuItem("Show storyboard") + { + State = { BindTarget = editorShowStoryboard }, + }, + new OsuMenuItemSpacer(), new ToggleMenuItem(EditorStrings.ShowHitMarkers) { State = { BindTarget = editorHitMarkers }, @@ -422,29 +452,7 @@ namespace osu.Game.Screens.Edit Items = new MenuItem[] { new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime), - new EditorMenuItem(EditorStrings.Bookmarks) - { - Items = new MenuItem[] - { - new EditorMenuItem(EditorStrings.AddBookmark, MenuItemType.Standard, addBookmarkAtCurrentTime) - { - Hotkey = new Hotkey(GlobalAction.EditorAddBookmark), - }, - new EditorMenuItem(EditorStrings.RemoveClosestBookmark, MenuItemType.Destructive, removeBookmarksInProximityToCurrentTime) - { - Hotkey = new Hotkey(GlobalAction.EditorRemoveClosestBookmark) - }, - new EditorMenuItem(EditorStrings.SeekToPreviousBookmark, MenuItemType.Standard, () => seekBookmark(-1)) - { - Hotkey = new Hotkey(GlobalAction.EditorSeekToPreviousBookmark) - }, - new EditorMenuItem(EditorStrings.SeekToNextBookmark, MenuItemType.Standard, () => seekBookmark(1)) - { - Hotkey = new Hotkey(GlobalAction.EditorSeekToNextBookmark) - }, - new EditorMenuItem(EditorStrings.ResetBookmarks, MenuItemType.Destructive, () => editorBeatmap.Bookmarks.Clear()) - } - } + bookmarkController.Menu, } } } @@ -466,12 +474,14 @@ namespace osu.Game.Screens.Edit changeHandler?.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); - editorBackgroundDim.BindValueChanged(_ => dimBackground()); + editorBackgroundDim.BindValueChanged(_ => setUpBackground()); } [Resolved] private MusicController musicController { get; set; } + protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(); + protected override void LoadComplete() { base.LoadComplete(); @@ -480,8 +490,6 @@ namespace osu.Game.Screens.Edit Mode.Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose; Mode.BindValueChanged(onModeChanged, true); - musicController.TrackChanged += onTrackChanged; - MutationTracker.InProgress.BindValueChanged(_ => { foreach (var item in saveRelatedMenuItems) @@ -493,6 +501,7 @@ namespace osu.Game.Screens.Edit { base.Dispose(isDisposing); + // redundant (should have happened via a `resetTrack()` call in `OnExiting()`), but done for safety musicController.TrackChanged -= onTrackChanged; } @@ -523,6 +532,8 @@ namespace osu.Game.Screens.Edit public void TestGameplay() { + clock.Stop(); + if (HasUnsavedChanges) { dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => @@ -561,6 +572,9 @@ namespace osu.Game.Screens.Edit return true; } + [CanBeNull] + internal event Action Saved; + /// /// Saves the currently edited beatmap. /// @@ -589,6 +603,7 @@ namespace osu.Game.Screens.Edit isNewBeatmap = false; updateLastSavedHash(); onScreenDisplay?.Display(new BeatmapEditorToast(ToastStrings.BeatmapSaved, editorBeatmap.BeatmapInfo.GetDisplayTitle())); + Saved?.Invoke(); return true; } @@ -596,6 +611,8 @@ namespace osu.Game.Screens.Edit { base.Update(); clock.ProcessFrame(); + + discardChangesMenuItem.Action.Disabled = !HasUnsavedChanges; } public bool OnPressed(KeyBindingPressEvent e) @@ -776,14 +793,6 @@ namespace osu.Game.Screens.Edit case GlobalAction.EditorSeekToNextSamplePoint: seekSamplePoint(1); return true; - - case GlobalAction.EditorSeekToPreviousBookmark: - seekBookmark(-1); - return true; - - case GlobalAction.EditorSeekToNextBookmark: - seekBookmark(1); - return true; } if (e.Repeat) @@ -791,14 +800,6 @@ namespace osu.Game.Screens.Edit switch (e.Action) { - case GlobalAction.EditorAddBookmark: - addBookmarkAtCurrentTime(); - return true; - - case GlobalAction.EditorRemoveClosestBookmark: - removeBookmarksInProximityToCurrentTime(); - return true; - case GlobalAction.EditorCloneSelection: Clone(); return true; @@ -826,24 +827,15 @@ namespace osu.Game.Screens.Edit case GlobalAction.EditorTestGameplay: bottomBar.TestGameplayButton.TriggerClick(); return true; + + case GlobalAction.EditorDiscardUnsavedChanges: + DiscardUnsavedChanges(); + return true; } return false; } - private void addBookmarkAtCurrentTime() - { - int bookmark = (int)clock.CurrentTimeAccurate; - int idx = editorBeatmap.Bookmarks.BinarySearch(bookmark); - if (idx < 0) - editorBeatmap.Bookmarks.Insert(~idx, bookmark); - } - - private void removeBookmarksInProximityToCurrentTime() - { - editorBeatmap.Bookmarks.RemoveAll(b => Math.Abs(b - clock.CurrentTimeAccurate) < 2000); - } - public void OnReleased(KeyBindingReleaseEvent e) { } @@ -851,23 +843,23 @@ namespace osu.Game.Screens.Edit public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); - dimBackground(); - resetTrack(true); + setUpBackground(); + setUpTrack(seekToStart: true); } public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); - dimBackground(); + setUpBackground(); + setUpTrack(); } - private void dimBackground() + private void setUpBackground() { ApplyToBackground(b => { - b.IgnoreUserSettings.Value = true; - b.DimWhenUserSettingsIgnored.Value = editorBackgroundDim.Value; - b.BlurAmount.Value = 0; + var editorBackground = (EditorBackgroundScreen)b; + editorBackground.ChangeClockSource(clock); }); } @@ -906,13 +898,9 @@ namespace osu.Game.Screens.Edit beatmap.EditorTimestamp = clock.CurrentTime; }); - ApplyToBackground(b => - { - b.DimWhenUserSettingsIgnored.Value = 0; - }); - + // `resetTrack()` MUST happen before `refetchBeatmap()`, because along other things, `refetchBeatmap()` causes a global working beatmap change, + // which would cause `EditorClock` to reload the track and automatically reapply adjustments to it if not preceded by `resetTrack()`. resetTrack(); - refetchBeatmap(); return base.OnExiting(e); @@ -921,7 +909,10 @@ namespace osu.Game.Screens.Edit public override void OnSuspending(ScreenTransitionEvent e) { base.OnSuspending(e); - clock.Stop(); + + // `resetTrack()` MUST happen before `refetchBeatmap()`, because along other things, `refetchBeatmap()` causes a global working beatmap change, + // which would cause `EditorClock` to reload the track and automatically reapply adjustments to it if not preceded by `resetTrack()`. + resetTrack(); refetchBeatmap(); } @@ -1027,12 +1018,26 @@ namespace osu.Game.Screens.Edit protected void Redo() => changeHandler?.RestoreState(1); + protected void DiscardUnsavedChanges() + { + if (!HasUnsavedChanges) + return; + + // we're not doing this via `changeHandler` because `changeHandler` has limited number of undo actions + // and therefore there's no guarantee that it even *has* the beatmap's last saved state in its history still. + dialogOverlay.Push(new DiscardUnsavedChangesDialog(() => + { + updateLastSavedHash(); // without this a second dialog will show (the standard "save unsaved changes" one that shows on exit). + SwitchToDifficulty(editorBeatmap.BeatmapInfo); + })); + } + protected void SetPreviewPointToCurrentTime() { editorBeatmap.PreviewTime.Value = (int)clock.CurrentTime; } - private void resetTrack(bool seekToStart = false) + private void setUpTrack(bool seekToStart = false) { clock.Stop(); @@ -1053,6 +1058,16 @@ namespace osu.Game.Screens.Edit clock.Seek(Math.Max(0, targetTime)); } + + clock.BindAdjustments(); + musicController.TrackChanged += onTrackChanged; + } + + private void resetTrack() + { + clock.Stop(); + clock.UnbindAdjustments(); + musicController.TrackChanged -= onTrackChanged; } private void onModeChanged(ValueChangedEvent e) @@ -1179,16 +1194,6 @@ namespace osu.Game.Screens.Edit clock.SeekSmoothlyTo(found.StartTime); } - private void seekBookmark(int direction) - { - int? targetBookmark = direction < 1 - ? editorBeatmap.Bookmarks.Cast().LastOrDefault(b => b < clock.CurrentTimeAccurate) - : editorBeatmap.Bookmarks.Cast().FirstOrDefault(b => b > clock.CurrentTimeAccurate); - - if (targetBookmark != null) - clock.SeekSmoothlyTo(targetBookmark.Value); - } - private void seekSamplePoint(int direction) { double currentTime = clock.CurrentTimeAccurate; @@ -1270,24 +1275,52 @@ namespace osu.Game.Screens.Edit yield return createDifficultyCreationMenu(); yield return createDifficultySwitchMenu(); yield return new OsuMenuItemSpacer(); - yield return new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } }; + yield return new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Destructive, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } }; yield return new OsuMenuItemSpacer(); var save = new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => attemptMutationOperation(Save)) { Hotkey = new Hotkey(PlatformAction.Save) }; saveRelatedMenuItems.Add(save); yield return save; - if (RuntimeInfo.IsDesktop) + yield return discardChangesMenuItem = new EditorMenuItem(GlobalActionKeyBindingStrings.EditorDiscardUnsavedChanges, MenuItemType.Destructive, DiscardUnsavedChanges) + { + Hotkey = new Hotkey(GlobalAction.EditorDiscardUnsavedChanges) + }; + + if (RuntimeInfo.OS != RuntimeInfo.Platform.Android) { var export = createExportMenu(); saveRelatedMenuItems.AddRange(export.Items); yield return export; + } - var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally); + if (RuntimeInfo.IsDesktop) + { + var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally); saveRelatedMenuItems.Add(externalEdit); yield return externalEdit; } + bool isSetMadeOfLegacyRulesetBeatmaps = (isNewBeatmap && Ruleset.Value.IsLegacyRuleset()) + || (!isNewBeatmap && Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Ruleset.IsLegacyRuleset())); + bool submissionAvailable = api.Endpoints.BeatmapSubmissionServiceUrl != null; + + if (isSetMadeOfLegacyRulesetBeatmaps && submissionAvailable) + { + var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap); + saveRelatedMenuItems.Add(upload); + yield return upload; + } + + if (editorBeatmap.BeatmapInfo.OnlineID > 0) + { + yield return new OsuMenuItemSpacer(); + yield return new EditorMenuItem(EditorStrings.OpenInfoPage, MenuItemType.Standard, + () => (Game as OsuGame)?.OpenUrlExternally(editorBeatmap.BeatmapInfo.GetOnlineURL(api, editorBeatmap.BeatmapInfo.Ruleset))); + yield return new EditorMenuItem(EditorStrings.OpenDiscussionPage, MenuItemType.Standard, + () => (Game as OsuGame)?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/beatmapsets/{editorBeatmap.BeatmapInfo.BeatmapSet!.OnlineID}/discussion/{editorBeatmap.BeatmapInfo.OnlineID}")); + } + yield return new OsuMenuItemSpacer(); yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit); } @@ -1327,6 +1360,42 @@ namespace osu.Game.Screens.Edit } } + private void submitBeatmap() + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + if (!editorBeatmap.HitObjects.Any()) + { + notifications?.Post(new SimpleNotification + { + Text = BeatmapSubmissionStrings.EmptyBeatmapsCannotBeSubmitted, + }); + return; + } + + if (HasUnsavedChanges) + { + dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => + { + if (!Save()) + return false; + + startSubmission(); + return true; + }))); + } + else + { + startSubmission(); + } + + void startSubmission() => this.Push(new BeatmapSubmissionScreen()); + } + private void exportBeatmap(bool legacy) { if (HasUnsavedChanges) @@ -1480,11 +1549,11 @@ namespace osu.Game.Screens.Edit loader?.CancelPendingDifficultySwitch(); } - public Task Reload() + public Task SaveAndReload() { var tcs = new TaskCompletionSource(); - dialogOverlay.Push(new ReloadEditorDialog( + dialogOverlay.Push(new SaveAndReloadEditorDialog( reload: () => { bool reloadedSuccessfully = attemptMutationOperation(() => diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 44f9646889..91ae4593dd 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -13,6 +13,7 @@ using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Edit; @@ -133,6 +134,8 @@ namespace osu.Game.Screens.Edit BeatmapInfo.Metadata.PreviewTime = s.NewValue; EndChange(); }); + + BeatmapVersion = PlayableBeatmap.BeatmapVersion; } /// @@ -286,6 +289,8 @@ namespace osu.Game.Screens.Edit set => PlayableBeatmap.Bookmarks = value; } + public int BeatmapVersion { get; set; } + public IBeatmap Clone() => (EditorBeatmap)MemberwiseClone(); private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; @@ -312,8 +317,13 @@ namespace osu.Game.Screens.Edit return; BeginChange(); + foreach (var h in SelectedHitObjects) + { action(h); + Update(h); + } + EndChange(); } @@ -451,6 +461,10 @@ namespace osu.Game.Screens.Edit if (batchPendingUpdates.Count == 0 && batchPendingDeletes.Count == 0 && batchPendingInserts.Count == 0) return; + // if the user is doing edits to this beatmaps via this flow, we better bump the beatmap version + // because the beatmap encoder can only output this specific beatmap version anyway, + // so *not* bumping it could lead to results that look misleading at best. + BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION; beatmapProcessor.PreProcess(); foreach (var h in batchPendingDeletes) processHitObject(h); diff --git a/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs b/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs index 4fe431498f..957c1d0969 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs @@ -41,6 +41,7 @@ namespace osu.Game.Screens.Edit rulesetBeatmapProcessor?.PostProcess(); autoGenerateBreaks(); + ensureNewComboAfterBreaks(); } private void autoGenerateBreaks() @@ -100,5 +101,40 @@ namespace osu.Game.Screens.Edit Beatmap.Breaks.Add(breakPeriod); } } + + private void ensureNewComboAfterBreaks() + { + var breakEnds = Beatmap.Breaks.Select(b => b.EndTime).OrderBy(t => t).ToList(); + + if (breakEnds.Count == 0) + return; + + int currentBreak = 0; + + IHasComboInformation? lastObj = null; + bool comboInformationUpdateRequired = false; + + foreach (var hitObject in Beatmap.HitObjects) + { + if (hitObject is not IHasComboInformation hasCombo) + continue; + + if (currentBreak < breakEnds.Count && hitObject.StartTime >= breakEnds[currentBreak]) + { + if (!hasCombo.NewCombo) + { + hasCombo.NewCombo = true; + comboInformationUpdateRequired = true; + } + + currentBreak += 1; + } + + if (comboInformationUpdateRequired) + hasCombo.UpdateComboInformation(lastObj); + + lastObj = hasCombo; + } + } } } diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 5b9c662c95..8b9bdb595d 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -6,6 +6,8 @@ using System; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -23,12 +25,15 @@ namespace osu.Game.Screens.Edit /// public partial class EditorClock : CompositeComponent, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { - public IBindable Track => track; + [CanBeNull] + public event Action TrackChanged; private readonly Bindable track = new Bindable(); public double TrackLength => track.Value?.IsLoaded == true ? track.Value.Length : 60000; + public AudioAdjustments AudioAdjustments { get; } = new AudioAdjustments(); + public ControlPointInfo ControlPointInfo => Beatmap.ControlPointInfo; public IBeatmap Beatmap { get; set; } @@ -56,6 +61,8 @@ namespace osu.Game.Screens.Edit underlyingClock = new FramedBeatmapClock(applyOffsets: true, requireDecoupling: true); AddInternal(underlyingClock); + + track.BindValueChanged(_ => TrackChanged?.Invoke()); } /// @@ -208,7 +215,16 @@ namespace osu.Game.Screens.Edit } } - public void ResetSpeedAdjustments() => underlyingClock.ResetSpeedAdjustments(); + public void BindAdjustments() => track.Value?.BindAdjustments(AudioAdjustments); + + public void UnbindAdjustments() => track.Value?.UnbindAdjustments(AudioAdjustments); + + public void ResetSpeedAdjustments() + { + AudioAdjustments.RemoveAllAdjustments(AdjustableProperty.Frequency); + AudioAdjustments.RemoveAllAdjustments(AdjustableProperty.Tempo); + underlyingClock.ResetSpeedAdjustments(); + } double IAdjustableClock.Rate { @@ -231,8 +247,12 @@ namespace osu.Game.Screens.Edit public void ChangeSource(IClock source) { + UnbindAdjustments(); + track.Value = source as Track; underlyingClock.ChangeSource(source); + + BindAdjustments(); } public IClock Source => underlyingClock.Source; diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs index 8a97e3dcb2..e906d74855 100644 --- a/osu.Game/Screens/Edit/ExternalEditScreen.cs +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -21,6 +21,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -156,7 +157,7 @@ namespace osu.Game.Screens.Edit }, new DangerousRoundedButton { - Text = "Finish editing and import changes", + Text = EditorStrings.FinishEditingExternally, Width = 350, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index 4377cc6219..eedde8b7a4 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -13,7 +14,11 @@ using osu.Game.Overlays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.Edit.GameplayTest @@ -28,6 +33,9 @@ namespace osu.Game.Screens.Edit.GameplayTest [Resolved] private MusicController musicController { get; set; } = null!; + [Cached(typeof(IGameplayLeaderboardProvider))] + private EmptyGameplayLeaderboardProvider leaderboardProvider = new EmptyGameplayLeaderboardProvider(); + public EditorPlayer(Editor editor) : base(new PlayerConfiguration { ShowResults = false }) { @@ -47,12 +55,25 @@ namespace osu.Game.Screens.Edit.GameplayTest return masterGameplayClockContainer; } + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); + + if (!LoadedBeatmapSuccessfully) + return; + + // This hack needs to be called to install its hooks before drawable hit objects get the chance to run update logic, + // because it will not work otherwise due to being too late (various effects of the objects getting missed will have already taken place). + preventMissOnPreviousHitObjects(); + } + protected override void LoadComplete() { base.LoadComplete(); + // this will notify components such as the skin's combo counter, which needs to happen on the update thread + // and therefore can't happen alongside `preventMissOnPreviousHitObjects()` in `LoadAsyncComplete()` markPreviousObjectsHit(); - markVisibleDrawableObjectsHit(); ScoreProcessor.HasCompleted.BindValueChanged(completed => { @@ -72,6 +93,11 @@ namespace osu.Game.Screens.Edit.GameplayTest foreach (var hitObject in enumerateHitObjects(DrawableRuleset.Objects, editorState.Time)) { var judgement = hitObject.Judgement; + // this is very dodgy because there's no guarantee that `JudgementResult` is the correct result type for the object. + // however, instantiating the correct one is difficult here, because `JudgementResult`s are constructed by DHOs + // and because of pooling we don't *have* a DHO to use here. + // this basically mostly attempts to fill holes in `ScoreProcessor` tallies + // so that gameplay can actually complete at the end of the map when entering gameplay test midway through it, and not much else. var result = new JudgementResult(hitObject, judgement) { Type = judgement.MaxResult, @@ -98,40 +124,45 @@ namespace osu.Game.Screens.Edit.GameplayTest } } - private void markVisibleDrawableObjectsHit() + private void preventMissOnPreviousHitObjects() { - if (!DrawableRuleset.Playfield.IsLoaded) + void preventMiss(HitObject hitObject) { - Schedule(markVisibleDrawableObjectsHit); - return; + var drawableObject = DrawableRuleset.Playfield.HitObjectContainer + .AliveObjects + .SingleOrDefault(it => it.HitObject == hitObject); + + if (drawableObject != null) + preventMissOnDrawable(drawableObject); } - foreach (var drawableObjectEntry in enumerateDrawableEntries( - DrawableRuleset.Playfield.AllHitObjects - .Select(ho => ho.Entry) - .Where(e => e != null) - .Cast(), editorState.Time)) + void preventMissOnDrawable(DrawableHitObject drawableObject) { - drawableObjectEntry.Result = new JudgementResult(drawableObjectEntry.HitObject, drawableObjectEntry.HitObject.Judgement) - { - Type = drawableObjectEntry.HitObject.Judgement.MaxResult - }; - } + foreach (var nested in drawableObject.NestedHitObjects) + preventMissOnDrawable(nested); - static IEnumerable enumerateDrawableEntries(IEnumerable entries, double cutoffTime) - { - foreach (var entry in entries) + if (drawableObject.Entry != null && drawableObject.HitObject.GetEndTime() < editorState.Time) { - foreach (var nested in enumerateDrawableEntries(entry.NestedEntries, cutoffTime)) - { - if (nested.HitObject.GetEndTime() < cutoffTime) - yield return nested; - } - - if (entry.HitObject.GetEndTime() < cutoffTime) - yield return entry; + var result = drawableObject.CreateResult(drawableObject.HitObject.Judgement); + result.Type = result.Judgement.MaxResult; + drawableObject.Entry.Result = result; } } + + void removeListener() + { + if (!DrawableRuleset.Playfield.IsLoaded) + { + Schedule(removeListener); + return; + } + + DrawableRuleset.Playfield.HitObjectUsageBegan -= preventMiss; + } + + DrawableRuleset.Playfield.HitObjectUsageBegan += preventMiss; + + Schedule(removeListener); } protected override void PrepareReplay() @@ -228,5 +259,7 @@ namespace osu.Game.Screens.Edit.GameplayTest editor.RestoreState(editorState); return base.OnExiting(e); } + + protected override ResultsScreen CreateResults(ScoreInfo score) => throw new NotSupportedException(); } } diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index f3d58a3c3c..e84b6bfc72 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -115,7 +115,9 @@ namespace osu.Game.Screens.Edit if (editorBeatmap.Bookmarks.Contains(newBookmark)) continue; - editorBeatmap.Bookmarks.Add(newBookmark); + int idx = editorBeatmap.Bookmarks.BinarySearch(newBookmark); + if (idx < 0) + editorBeatmap.Bookmarks.Insert(~idx, newBookmark); } } diff --git a/osu.Game/Screens/Edit/ReloadEditorDialog.cs b/osu.Game/Screens/Edit/SaveAndReloadEditorDialog.cs similarity index 86% rename from osu.Game/Screens/Edit/ReloadEditorDialog.cs rename to osu.Game/Screens/Edit/SaveAndReloadEditorDialog.cs index 72a9f81347..b73c7cfff8 100644 --- a/osu.Game/Screens/Edit/ReloadEditorDialog.cs +++ b/osu.Game/Screens/Edit/SaveAndReloadEditorDialog.cs @@ -8,9 +8,9 @@ using osu.Game.Localisation; namespace osu.Game.Screens.Edit { - public partial class ReloadEditorDialog : PopupDialog + public partial class SaveAndReloadEditorDialog : PopupDialog { - public ReloadEditorDialog(Action reload, Action cancel) + public SaveAndReloadEditorDialog(Action reload, Action cancel) { HeaderText = EditorDialogsStrings.EditorReloadDialogHeader; diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index 8de7f86523..865fe05c54 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Beatmaps.Formats; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Skinning; @@ -54,6 +55,8 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.BeatmapSkin.ComboColours.Clear(); Beatmap.BeatmapSkin.ComboColours.AddRange(comboColours.Colours); + updateAddButtonVisibility(); + syncingColours = false; } }); @@ -68,8 +71,14 @@ namespace osu.Game.Screens.Edit.Setup comboColours.Colours.Clear(); comboColours.Colours.AddRange(Beatmap.BeatmapSkin?.ComboColours); + updateAddButtonVisibility(); + syncingColours = false; }); + + updateAddButtonVisibility(); + + void updateAddButtonVisibility() => comboColours.CanAdd.Value = comboColours.Colours.Count < LegacyBeatmapDecoder.MAX_COMBO_COLOUR_COUNT; } } } diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index 88241451cf..d0fc9cc3e1 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -103,6 +103,7 @@ namespace osu.Game.Screens.Edit.Setup { Caption = EditorSetupStrings.TickRate, HintText = EditorSetupStrings.TickRateDescription, + KeyboardStep = 1, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, diff --git a/osu.Game/Screens/Edit/Setup/FormBeatmapFileSelector.cs b/osu.Game/Screens/Edit/Setup/FormBeatmapFileSelector.cs new file mode 100644 index 0000000000..53287383ec --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/FormBeatmapFileSelector.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Edit.Setup +{ + /// + /// A type of dedicated to beatmap resources. + /// + /// + /// This expands on by adding an intermediate step before finalisation + /// to choose whether the selected file should be applied to the current difficulty or all difficulties in the set, + /// the user's choice is saved in before the file selection is finalised and propagated to . + /// + public partial class FormBeatmapFileSelector : FormFileSelector + { + private readonly bool beatmapHasMultipleDifficulties; + + public readonly Bindable ApplyToAllDifficulties = new Bindable(true); + + public FormBeatmapFileSelector(bool beatmapHasMultipleDifficulties, params string[] handledExtensions) + : base(handledExtensions) + { + this.beatmapHasMultipleDifficulties = beatmapHasMultipleDifficulties; + } + + protected override FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) + { + var popover = new BeatmapFileChooserPopover(handledExtensions, current, chooserPath, beatmapHasMultipleDifficulties); + popover.ApplyToAllDifficulties.BindTo(ApplyToAllDifficulties); + return popover; + } + + private partial class BeatmapFileChooserPopover : FileChooserPopover + { + private readonly bool beatmapHasMultipleDifficulties; + + public readonly Bindable ApplyToAllDifficulties = new Bindable(true); + + private Container selectApplicationScopeContainer = null!; + + public BeatmapFileChooserPopover(string[] handledExtensions, Bindable current, string? chooserPath, bool beatmapHasMultipleDifficulties) + : base(handledExtensions, current, chooserPath) + { + this.beatmapHasMultipleDifficulties = beatmapHasMultipleDifficulties; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + Add(selectApplicationScopeContainer = new InputBlockingContainer + { + Alpha = 0f, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background6.Opacity(0.9f), + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 10f, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Margin = new MarginPadding(30), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = EditorSetupStrings.ApplicationScopeSelectionTitle, + Margin = new MarginPadding { Bottom = 20f }, + }, + new RoundedButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300f, + Text = EditorSetupStrings.ApplyToAllDifficulties, + Action = () => + { + ApplyToAllDifficulties.Value = true; + updateFileSelection(); + }, + BackgroundColour = colours.Red2, + }, + new RoundedButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300f, + Text = EditorSetupStrings.ApplyToThisDifficulty, + Action = () => + { + ApplyToAllDifficulties.Value = false; + updateFileSelection(); + }, + }, + } + } + } + }, + } + }); + } + + protected override void OnFileSelected(FileInfo file) + { + if (beatmapHasMultipleDifficulties) + selectApplicationScopeContainer.FadeIn(200, Easing.InQuint); + else + base.OnFileSelected(file); + } + + private void updateFileSelection() + { + Debug.Assert(FileSelector.CurrentFile.Value != null); + base.OnFileSelected(FileSelector.CurrentFile.Value); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 20c0a74d84..56b5d8aaaf 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -3,7 +3,7 @@ using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; @@ -25,36 +25,40 @@ namespace osu.Game.Screens.Edit.Setup private FormTextBox sourceTextBox = null!; private FormTextBox tagsTextBox = null!; + private bool reloading; + private bool dirty; + public override LocalisableString Title => EditorSetupStrings.MetadataHeader; - [BackgroundDependencyLoader] - private void load() - { - var metadata = Beatmap.Metadata; + [Resolved] + private Editor? editor { get; set; } + [BackgroundDependencyLoader] + private void load(SetupScreen? setupScreen) + { Children = new[] { - ArtistTextBox = createTextBox(EditorSetupStrings.Artist, - !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist), - RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, - !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - TitleTextBox = createTextBox(EditorSetupStrings.Title, - !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title), - RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, - !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), - difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), - sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), - tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) + ArtistTextBox = createTextBox(EditorSetupStrings.Artist), + RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist), + TitleTextBox = createTextBox(EditorSetupStrings.Title), + RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle), + creatorTextBox = createTextBox(EditorSetupStrings.Creator), + difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName), + sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource), + tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoMapperTags) }; + + if (setupScreen != null) + setupScreen.MetadataChanged += reloadMetadata; + + reloadMetadata(); } - private TTextBox createTextBox(LocalisableString label, string initialValue) + private TTextBox createTextBox(LocalisableString label) where TTextBox : FormTextBox, new() => new TTextBox { Caption = label, - Current = { Value = initialValue }, TabbableContentContainer = this }; @@ -69,7 +73,19 @@ namespace osu.Game.Screens.Edit.Setup TitleTextBox.Current.BindValueChanged(title => transferIfRomanised(title.NewValue, RomanisedTitleTextBox)); foreach (var item in Children.OfType()) - item.OnCommit += onCommit; + { + // Apply immediately on any change to ensure that if the user hits Ctrl+S after making a change (without committing) + // it will still apply to the beatmap. + item.Current.BindValueChanged(_ => applyMetadata()); + item.OnCommit += (_, newText) => + { + if (newText && dirty) + Beatmap.SaveState(); + }; + } + + if (editor != null) + editor.Saved += () => dirty = false; updateReadOnlyState(); } @@ -88,29 +104,44 @@ namespace osu.Game.Screens.Edit.Setup RomanisedTitleTextBox.ReadOnly = MetadataUtils.IsRomanised(TitleTextBox.Current.Value); } - private void onCommit(TextBox sender, bool newText) + private void reloadMetadata() { - if (!newText) return; + reloading = true; - // for now, update on commit rather than making BeatmapMetadata bindables. - // after switching database engines we can reconsider if switching to bindables is a good direction. - updateMetadata(); + var metadata = Beatmap.Metadata; + + RomanisedArtistTextBox.ReadOnly = false; + RomanisedTitleTextBox.ReadOnly = false; + + ArtistTextBox.Current.Value = !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist; + RomanisedArtistTextBox.Current.Value = !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode); + TitleTextBox.Current.Value = !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title; + RomanisedTitleTextBox.Current.Value = !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.TitleUnicode); + creatorTextBox.Current.Value = metadata.Author.Username; + difficultyTextBox.Current.Value = Beatmap.BeatmapInfo.DifficultyName; + sourceTextBox.Current.Value = metadata.Source; + tagsTextBox.Current.Value = metadata.Tags; + + updateReadOnlyState(); + + reloading = false; } - private void updateMetadata() + private void applyMetadata() { + if (reloading) + return; + Beatmap.Metadata.ArtistUnicode = ArtistTextBox.Current.Value; Beatmap.Metadata.Artist = RomanisedArtistTextBox.Current.Value; - Beatmap.Metadata.TitleUnicode = TitleTextBox.Current.Value; Beatmap.Metadata.Title = RomanisedTitleTextBox.Current.Value; - Beatmap.Metadata.Author.Username = creatorTextBox.Current.Value; Beatmap.BeatmapInfo.DifficultyName = difficultyTextBox.Current.Value; Beatmap.Metadata.Source = sourceTextBox.Current.Value; Beatmap.Metadata.Tags = tagsTextBox.Current.Value; - Beatmap.SaveState(); + dirty = true; } private partial class FormRomanisedTextBox : FormTextBox @@ -119,7 +150,10 @@ namespace osu.Game.Screens.Edit.Setup private partial class RomanisedTextBox : InnerTextBox { - protected override bool AllowIme => false; + public RomanisedTextBox() + { + InputProperties = new TextInputProperties(TextInputType.Text, false); + } protected override bool CanAddCharacter(char character) => MetadataUtils.IsRomanised(character); diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 59a0520a52..f52d865d5f 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -1,23 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.IO; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Overlays; using osu.Game.Localisation; +using osu.Game.Models; +using osu.Game.Overlays; +using osu.Game.Screens.Backgrounds; using osu.Game.Utils; namespace osu.Game.Screens.Edit.Setup { public partial class ResourcesSection : SetupSection { - private FormFileSelector audioTrackChooser = null!; - private FormFileSelector backgroundChooser = null!; + private FormBeatmapFileSelector audioTrackChooser = null!; + private FormBeatmapFileSelector backgroundChooser = null!; public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; @@ -31,10 +35,10 @@ namespace osu.Game.Screens.Edit.Setup private IBindable working { get; set; } = null!; [Resolved] - private EditorBeatmap editorBeatmap { get; set; } = null!; + private Editor? editor { get; set; } [Resolved] - private Editor? editor { get; set; } + private SetupScreen setupScreen { get; set; } = null!; private SetupScreenHeaderBackground headerBackground = null!; @@ -47,14 +51,16 @@ namespace osu.Game.Screens.Edit.Setup Height = 110, }; + bool beatmapHasMultipleDifficulties = working.Value.BeatmapSetInfo.Beatmaps.Count > 1; + Children = new Drawable[] { - backgroundChooser = new FormFileSelector(SupportedExtensions.IMAGE_EXTENSIONS) + backgroundChooser = new FormBeatmapFileSelector(beatmapHasMultipleDifficulties, SupportedExtensions.IMAGE_EXTENSIONS) { Caption = GameplaySettingsStrings.BackgroundHeader, PlaceholderText = EditorSetupStrings.ClickToSelectBackground, }, - audioTrackChooser = new FormFileSelector(SupportedExtensions.AUDIO_EXTENSIONS) + audioTrackChooser = new FormBeatmapFileSelector(beatmapHasMultipleDifficulties, SupportedExtensions.AUDIO_EXTENSIONS) { Caption = EditorSetupStrings.AudioTrack, PlaceholderText = EditorSetupStrings.ClickToSelectTrack, @@ -73,76 +79,165 @@ namespace osu.Game.Screens.Edit.Setup audioTrackChooser.Current.BindValueChanged(audioTrackChanged); } - public bool ChangeBackgroundImage(FileInfo source) + public bool ChangeBackgroundImage(FileInfo source, bool applyToAllDifficulties) { if (!source.Exists) return false; - var set = working.Value.BeatmapSetInfo; + changeResource(source, applyToAllDifficulties, @"bg", + metadata => metadata.BackgroundFile, + (metadata, name) => metadata.BackgroundFile = name); - var destination = new FileInfo($@"bg{source.Extension}"); - - // remove the previous background for now. - // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.GetFile(working.Value.Metadata.BackgroundFile); - - using (var stream = source.OpenRead()) - { - if (oldFile != null) - beatmaps.DeleteFile(set, oldFile); - - beatmaps.AddFile(set, stream, destination.Name); - } - - editorBeatmap.SaveState(); - - working.Value.Metadata.BackgroundFile = destination.Name; headerBackground.UpdateBackground(); - - editor?.ApplyToBackground(bg => bg.RefreshBackground()); - + editor?.ApplyToBackground(bg => ((EditorBackgroundScreen)bg).RefreshBackground()); return true; } - public bool ChangeAudioTrack(FileInfo source) + public bool ChangeAudioTrack(FileInfo source, bool applyToAllDifficulties) { if (!source.Exists) return false; - var set = working.Value.BeatmapSetInfo; + string artist; + string title; - var destination = new FileInfo($@"audio{source.Extension}"); - - // remove the previous audio track for now. - // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.GetFile(working.Value.Metadata.AudioFile); - - using (var stream = source.OpenRead()) + try { - if (oldFile != null) - beatmaps.DeleteFile(set, oldFile); - - beatmaps.AddFile(set, stream, destination.Name); + using (var tagSource = TagLibUtils.GetTagLibFile(source.FullName)) + { + artist = tagSource.Tag.JoinedAlbumArtists ?? tagSource.Tag.JoinedPerformers; + title = tagSource.Tag.Title; + } + } + catch (Exception e) + { + Logger.Error(e, "The selected audio track appears to be corrupted. Please select another one."); + return false; } - working.Value.Metadata.AudioFile = destination.Name; + changeResource(source, applyToAllDifficulties, @"audio", + metadata => metadata.AudioFile, + (metadata, name) => + { + metadata.AudioFile = name; + + if (!string.IsNullOrWhiteSpace(artist)) + { + metadata.ArtistUnicode = artist; + metadata.Artist = MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode); + } + + if (!string.IsNullOrEmpty(title)) + { + metadata.TitleUnicode = title; + metadata.Title = MetadataUtils.StripNonRomanisedCharacters(metadata.TitleUnicode); + } + }); - editorBeatmap.SaveState(); music.ReloadCurrentTrack(); - + setupScreen.MetadataChanged?.Invoke(); return true; } + private void changeResource(FileInfo source, bool applyToAllDifficulties, string baseFilename, Func readFilename, Action writeMetadata) + { + var set = working.Value.BeatmapSetInfo; + var beatmap = working.Value.BeatmapInfo; + + var otherBeatmaps = set.Beatmaps.Where(b => !b.Equals(beatmap)); + + // First, clean up files which will no longer be used. + if (applyToAllDifficulties) + { + foreach (var b in set.Beatmaps) + { + if (set.GetFile(readFilename(b.Metadata)) is RealmNamedFileUsage otherExistingFile) + beatmaps.DeleteFile(set, otherExistingFile); + } + } + else + { + RealmNamedFileUsage? oldFile = set.GetFile(readFilename(working.Value.Metadata)); + + if (oldFile != null) + { + bool oldFileUsedInOtherDiff = otherBeatmaps + .Any(b => readFilename(b.Metadata) == oldFile.Filename); + if (!oldFileUsedInOtherDiff) + beatmaps.DeleteFile(set, oldFile); + } + } + + // Choose a new filename that doesn't clash with any other existing files. + string newFilename = $"{baseFilename}{source.Extension}"; + + if (set.GetFile(newFilename) != null) + { + string[] existingFilenames = set.Files.Select(f => f.Filename).Where(f => + f.StartsWith(baseFilename, StringComparison.OrdinalIgnoreCase) && + f.EndsWith(source.Extension, StringComparison.OrdinalIgnoreCase)).ToArray(); + newFilename = NamingUtils.GetNextBestFilename(existingFilenames, $@"{baseFilename}{source.Extension}"); + } + + using (var stream = source.OpenRead()) + beatmaps.AddFile(set, stream, newFilename); + + if (applyToAllDifficulties) + { + foreach (var b in otherBeatmaps) + { + writeMetadata(b.Metadata, newFilename); + + // save the difficulty to re-encode the .osu file, updating any reference of the old filename. + // + // note that this triggers a full save flow, including triggering a difficulty calculation. + // this is not a cheap operation and should be reconsidered in the future. + var beatmapWorking = beatmaps.GetWorkingBeatmap(b); + beatmaps.Save(b, beatmapWorking.GetPlayableBeatmap(b.Ruleset), beatmapWorking.GetSkin()); + } + } + + writeMetadata(beatmap.Metadata, newFilename); + + // editor change handler cannot be aware of any file changes or other difficulties having their metadata modified. + // for simplicity's sake, trigger a save when changing any resource to ensure the change is correctly saved. + editor?.Save(); + } + + // to avoid scaring users, both background & audio choosers use fake `FileInfo`s with user-friendly filenames + // when displaying an imported beatmap rather than the actual SHA-named file in storage. + // however, that means that when a background or audio file is chosen that is broken or doesn't exist on disk when switching away from the fake files, + // the rollback could enter an infinite loop, because the fake `FileInfo`s *also* don't exist on disk - at least not in the fake location they indicate. + // to circumvent this issue, just allow rollback to proceed always without actually running any of the change logic to ensure visual consistency. + // note that this means that `Change{BackgroundImage,AudioTrack}()` are required to not have made any modifications to the beatmap files + // (or at least cleaned them up properly themselves) if they return `false`. + private bool rollingBackBackgroundChange; + private bool rollingBackAudioChange; + private void backgroundChanged(ValueChangedEvent file) { - if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue)) + if (rollingBackBackgroundChange) + return; + + if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue, backgroundChooser.ApplyToAllDifficulties.Value)) + { + rollingBackBackgroundChange = true; backgroundChooser.Current.Value = file.OldValue; + rollingBackBackgroundChange = false; + } } private void audioTrackChanged(ValueChangedEvent file) { - if (file.NewValue == null || !ChangeAudioTrack(file.NewValue)) + if (rollingBackAudioChange) + return; + + if (file.NewValue == null || !ChangeAudioTrack(file.NewValue, audioTrackChooser.ApplyToAllDifficulties.Value)) + { + rollingBackAudioChange = true; audioTrackChooser.Current.Value = file.OldValue; + rollingBackAudioChange = false; + } } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index f8c4998263..97e12ae096 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; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,12 +14,15 @@ using osuTK; namespace osu.Game.Screens.Edit.Setup { + [Cached] public partial class SetupScreen : EditorScreen { public const float COLUMN_WIDTH = 450; public const float SPACING = 28; public const float MAX_WIDTH = 2 * COLUMN_WIDTH + SPACING; + public Action? MetadataChanged { get; set; } + public SetupScreen() : base(EditorScreenMode.SongSetup) { diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.cs new file mode 100644 index 0000000000..cf2fef25d5 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionOverlay.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.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Edit.Submission +{ + public partial class BeatmapSubmissionOverlay : WizardOverlay + { + public BeatmapSubmissionOverlay() + : base(OverlayColourScheme.Aquamarine) + { + } + + [BackgroundDependencyLoader] + private void load(IBindable beatmap) + { + if (beatmap.Value.BeatmapSetInfo.OnlineID <= 0) + { + AddStep(); + AddStep(); + } + + AddStep(); + + Header.Title = BeatmapSubmissionStrings.BeatmapSubmissionTitle; + Header.Description = BeatmapSubmissionStrings.BeatmapSubmissionDescription; + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs new file mode 100644 index 0000000000..78066edc7e --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs @@ -0,0 +1,498 @@ +// Copyright (c) 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.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Development; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO.Archives; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + public partial class BeatmapSubmissionScreen : OsuScreen + { + private BeatmapSubmissionOverlay overlay = null!; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + protected override bool InitialBackButtonVisibility => false; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + [Resolved] + private Storage storage { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Cached] + private BeatmapSubmissionSettings settings { get; } = new BeatmapSubmissionSettings(); + + private Container submissionProgress = null!; + private SubmissionStageProgress exportStep = null!; + private SubmissionStageProgress createSetStep = null!; + private SubmissionStageProgress uploadStep = null!; + private SubmissionStageProgress updateStep = null!; + private Container successContainer = null!; + private Container flashLayer = null!; + + private uint? beatmapSetId; + private MemoryStream? beatmapPackageStream; + + private ProgressNotification? exportProgressNotification; + private ProgressNotification? updateProgressNotification; + + private Live? importedSet; + + private Sample completedSample = null!; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + AddRangeInternal(new Drawable[] + { + overlay = new BeatmapSubmissionOverlay(), + submissionProgress = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + AutoSizeDuration = 400, + AutoSizeEasing = Easing.OutQuint, + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.6f, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(20), + Spacing = new Vector2(5), + Children = new Drawable[] + { + createSetStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Preparing, + StageIndex = 0, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + exportStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Exporting, + StageIndex = 1, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + uploadStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Uploading, + StageIndex = 2, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + updateStep = new SubmissionStageProgress + { + StageDescription = BeatmapSubmissionStrings.Finishing, + StageIndex = 3, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + successContainer = new Container + { + Padding = new MarginPadding(20), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Child = flashLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Depth = float.MinValue, + Alpha = 0, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + } + } + } + } + }); + + overlay.State.BindValueChanged(_ => + { + if (overlay.State.Value == Visibility.Hidden) + { + if (!overlay.Completed) + { + allowExit(); + this.Exit(); + } + else + { + submissionProgress.FadeIn(200, Easing.OutQuint); + createBeatmapSet(); + } + } + }); + + completedSample = audio.Samples.Get(@"UI/bss-complete"); + + if (Beatmap.Value.BeatmapSetInfo.OnlineID > 0) + { + var req = new GetBeatmapSetRequest(Beatmap.Value.BeatmapSetInfo.OnlineID); + api.Queue(req); + settings.LatestOnlineStateRequest = req; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + configManager.BindWith(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, settings.NotifyOnDiscussionReplies); + } + + private void createBeatmapSet() + { + bool beatmapHasOnlineId = Beatmap.Value.BeatmapSetInfo.OnlineID > 0; + + PutBeatmapSetRequest createRequest; + + if (beatmapHasOnlineId) + { + createRequest = PutBeatmapSetRequest.UpdateExisting( + (uint)Beatmap.Value.BeatmapSetInfo.OnlineID, + Beatmap.Value.BeatmapSetInfo.Beatmaps.Where(b => b.OnlineID > 0).Select(b => (uint)b.OnlineID).ToArray(), + (uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count(b => b.OnlineID <= 0), + settings); + log($"Updating existing beatmap set (id:{createRequest.BeatmapSetID} beatmapsToKeep:[{string.Join(",", createRequest.BeatmapsToKeep)}] beatmapsToCreate:{createRequest.BeatmapsToCreate})"); + } + else + { + createRequest = PutBeatmapSetRequest.CreateNew((uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count, settings); + log($"Creating new beatmap set (beatmapsToCreate:{createRequest.BeatmapsToCreate})"); + } + + createRequest.Success += async response => + { + createSetStep.SetCompleted(); + beatmapSetId = response.BeatmapSetId; + + // at this point the set has an assigned online ID. + // it's important to proactively store it to the realm database, + // so that in the event in further failures in the process, the online ID is not lost. + // losing it can incur creation of redundant new sets server-side, or even cause online ID confusion. + if (!beatmapHasOnlineId) + { + await realmAccess.WriteAsync(r => + { + var refetchedSet = r.Find(Beatmap.Value.BeatmapSetInfo.ID); + refetchedSet!.OnlineID = (int)beatmapSetId.Value; + }).ConfigureAwait(true); + } + + await createBeatmapPackage(response).ConfigureAwait(true); + }; + createRequest.Failure += ex => + { + createSetStep.SetFailed(ex.Message); + log($"Beatmap set creation/update failed: {ex}"); + allowExit(); + }; + + createSetStep.SetInProgress(); + api.Queue(createRequest); + } + + private async Task createBeatmapPackage(PutBeatmapSetResponse response) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + exportStep.SetInProgress(); + + try + { + beatmapPackageStream = new MemoryStream(); + exportProgressNotification = new ProgressNotification(); + + var legacyBeatmapExporter = new SubmissionBeatmapExporter(storage, response); + + await legacyBeatmapExporter + .ExportToStreamAsync(Beatmap.Value.BeatmapSetInfo.ToLive(realmAccess), beatmapPackageStream, exportProgressNotification) + .ConfigureAwait(true); + } + catch (Exception ex) + { + exportStep.SetFailed(ex.Message); + exportProgressNotification = null; + log($"Export failed: {ex}"); + allowExit(); + return; + } + + exportStep.SetCompleted(); + exportProgressNotification = null; + + await Task.Delay(200).ConfigureAwait(true); + + if (response.Files.Count > 0) + await patchBeatmapSet(response.Files).ConfigureAwait(true); + else + replaceBeatmapSet(); + } + + private async Task patchBeatmapSet(ICollection onlineFiles) + { + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + log("Determining list of files to patch..."); + + var onlineFilesByFilename = onlineFiles.ToDictionary(f => f.Filename, f => f.SHA2Hash); + + // disposing the `ArchiveReader` makes the underlying stream no longer readable which we don't want. + // make a local copy to defend against it. + using var archiveReader = new ZipArchiveReader(new MemoryStream(beatmapPackageStream.ToArray())); + var filesToUpdate = new HashSet(); + + foreach (string filename in archiveReader.Filenames) + { + string localHash = archiveReader.GetStream(filename).ComputeSHA2Hash(); + + if (!onlineFilesByFilename.Remove(filename, out string? onlineHash)) + { + log($@"new file: {filename}"); + filesToUpdate.Add(filename); + continue; + } + + if (!localHash.Equals(onlineHash, StringComparison.OrdinalIgnoreCase)) + { + log($@"changed file: {filename} (localHash:{localHash} onlineHash:{onlineHash})"); + filesToUpdate.Add(filename); + } + } + + var changedFiles = new Dictionary(); + + foreach (string file in filesToUpdate) + changedFiles.Add(file, await archiveReader.GetStream(file).ReadAllBytesToArrayAsync().ConfigureAwait(true)); + + var patchRequest = new PatchBeatmapPackageRequest(beatmapSetId.Value); + patchRequest.FilesChanged.AddRange(changedFiles); + patchRequest.FilesDeleted.AddRange(onlineFilesByFilename.Keys); + + foreach (string file in patchRequest.FilesDeleted) + log($@"deleted file: {file}"); + + patchRequest.Success += uploadCompleted; + patchRequest.Failure += ex => + { + uploadStep.SetFailed(ex.Message); + log($"Upload failed: {ex}"); + allowExit(); + }; + patchRequest.Progressed += (current, total) => uploadStep.SetInProgress(total > 0 ? (float)current / total : null); + + api.Queue(patchRequest); + uploadStep.SetInProgress(); + } + + private void replaceBeatmapSet() + { + log("Peforming full package upload..."); + + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + var uploadRequest = new ReplaceBeatmapPackageRequest(beatmapSetId.Value, beatmapPackageStream.ToArray()); + + uploadRequest.Success += uploadCompleted; + uploadRequest.Failure += ex => + { + uploadStep.SetFailed(ex.Message); + log($"Full package upload failed: {ex}"); + allowExit(); + }; + uploadRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / Math.Max(total, 1)); + + api.Queue(uploadRequest); + uploadStep.SetInProgress(); + } + + private void uploadCompleted() + { + uploadStep.SetCompleted(); + updateLocalBeatmap().ConfigureAwait(true); + } + + private async Task updateLocalBeatmap() + { + log(@"Updating local beatmap set..."); + + Debug.Assert(beatmapSetId != null); + Debug.Assert(beatmapPackageStream != null); + + updateStep.SetInProgress(); + await Task.Delay(200).ConfigureAwait(true); + + try + { + importedSet = await beatmaps.ImportAsUpdate( + updateProgressNotification = new ProgressNotification(), + new ImportTask(beatmapPackageStream, $"{beatmapSetId}.osz"), + Beatmap.Value.BeatmapSetInfo).ConfigureAwait(true); + } + catch (Exception ex) + { + updateStep.SetFailed(ex.Message); + log($@"Local update failed: {ex}"); + allowExit(); + return; + } + + updateStep.SetCompleted(); + showBeatmapCard(); + allowExit(); + + if (configManager.Get(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission)) + { + await Task.Delay(1000).ConfigureAwait(true); + game?.OpenUrlExternally($"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetId}"); + } + } + + private void showBeatmapCard() + { + Debug.Assert(beatmapSetId != null); + + var getBeatmapSetRequest = new GetBeatmapSetRequest((int)beatmapSetId.Value); + getBeatmapSetRequest.Success += beatmapSet => + { + LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded => + { + successContainer.Add(loaded); + flashLayer.FadeOutFromOne(2000, Easing.OutQuint); + }); + + completedSample.Play(); + }; + + api.Queue(getBeatmapSetRequest); + } + + private void allowExit() + { + BackButtonVisibility.Value = true; + } + + protected override void Update() + { + base.Update(); + + if (exportProgressNotification != null && exportProgressNotification.Ongoing) + exportStep.SetInProgress(exportProgressNotification.Progress); + + if (updateProgressNotification != null && updateProgressNotification.Ongoing) + updateStep.SetInProgress(updateProgressNotification.Progress); + } + + public override bool OnExiting(ScreenExitEvent e) + { + // We probably want a method of cancelling in the future… + if (!BackButtonVisibility.Value) + return true; + + if (importedSet != null) + { + game?.PerformFromScreen(s => + { + if (s is OsuScreen osuScreen) + { + Debug.Assert(importedSet != null); + var targetBeatmap = importedSet.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == Beatmap.Value.BeatmapInfo.DifficultyName) + ?? importedSet.Value.Beatmaps.First(); + osuScreen.Beatmap.Value = beatmaps.GetWorkingBeatmap(targetBeatmap); + } + + s.Push(new EditorLoader()); + }, [typeof(SongSelect)]); + + return false; + } + + return base.OnExiting(e); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + overlay.Show(); + } + + private static void log(string message) + => Logger.Log($@"[{nameof(BeatmapSubmissionScreen)}] {message}", LoggingTarget.Database); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + beatmapPackageStream?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.cs new file mode 100644 index 0000000000..a1f3861d29 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionSettings.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.Bindables; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Screens.Edit.Submission +{ + public class BeatmapSubmissionSettings + { + public GetBeatmapSetRequest? LatestOnlineStateRequest { get; set; } + + public Bindable Target { get; } = new Bindable(); + + public Bindable NotifyOnDiscussionReplies { get; } = new Bindable(); + } +} diff --git a/osu.Game/Screens/Edit/Submission/ScreenContentPermissions.cs b/osu.Game/Screens/Edit/Submission/ScreenContentPermissions.cs new file mode 100644 index 0000000000..92a4ac4e4e --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/ScreenContentPermissions.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.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit.Submission +{ + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.ContentPermissions))] + public partial class ScreenContentPermissions : WizardScreen + { + [BackgroundDependencyLoader] + private void load(OsuGame? game) + { + Content.AddRange(new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = BeatmapSubmissionStrings.ContentPermissionsDisclaimer, + }, + new RoundedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 450, + Text = BeatmapSubmissionStrings.CheckContentUsageGuidelines, + Action = () => game?.ShowWiki(@"Rules/Content_usage_permissions"), + }, + }); + } + + public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ContentPermissionsAcknowledgement; + } +} diff --git a/osu.Game/Screens/Edit/Submission/ScreenFrequentlyAskedQuestions.cs b/osu.Game/Screens/Edit/Submission/ScreenFrequentlyAskedQuestions.cs new file mode 100644 index 0000000000..861c5051f4 --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/ScreenFrequentlyAskedQuestions.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.FrequentlyAskedQuestions))] + public partial class ScreenFrequentlyAskedQuestions : WizardScreen + { + [BackgroundDependencyLoader] + private void load(OsuGame? game, IAPIProvider api) + { + Content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.BeatmapRankingCriteriaDescription, + ButtonText = BeatmapSubmissionStrings.BeatmapRankingCriteria, + Action = () => game?.ShowWiki(@"Ranking_Criteria"), + }, + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.SubmissionProcessDescription, + ButtonText = BeatmapSubmissionStrings.SubmissionProcess, + Action = () => game?.ShowWiki(@"Beatmap_ranking_procedure"), + }, + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.MappingHelpForumDescription, + ButtonText = BeatmapSubmissionStrings.MappingHelpForum, + Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/forums/56"), + }, + new FormButton + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.ModdingQueuesForumDescription, + ButtonText = BeatmapSubmissionStrings.ModdingQueuesForum, + Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/forums/60"), + }, + }, + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs new file mode 100644 index 0000000000..26b99d4e4d --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/ScreenSubmissionSettings.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +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.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + [LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.SubmissionSettings))] + public partial class ScreenSubmissionSettings : WizardScreen + { + private readonly BindableBool loadInBrowserAfterSubmission = new BindableBool(); + + public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ConfirmSubmission; + + [Resolved] + private BeatmapSubmissionSettings settings { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager, OsuColour colours) + { + configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission); + + Content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new FormEnumDropdown + { + RelativeSizeAxes = Axes.X, + Caption = BeatmapSubmissionStrings.BeatmapSubmissionTargetCaption, + Current = settings.Target, + }, + new FormCheckBox + { + Caption = BeatmapSubmissionStrings.NotifyOnDiscussionReplies, + Current = settings.NotifyOnDiscussionReplies, + }, + new FormCheckBox + { + Caption = BeatmapSubmissionStrings.LoadInBrowserAfterSubmission, + Current = loadInBrowserAfterSubmission, + }, + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE, weight: FontWeight.Bold)) + { + RelativeSizeAxes = Axes.X, + Colour = colours.Orange1, + Text = BeatmapSubmissionStrings.LegacyExportDisclaimer, + Padding = new MarginPadding { Top = 20 } + }, + } + }); + + switch (settings.LatestOnlineStateRequest?.CompletionState) + { + case APIRequestCompletionState.Completed: + setSubmissionTargetFromLatestOnlineState(); + break; + + case APIRequestCompletionState.Waiting: + settings.Target.Disabled = true; + settings.LatestOnlineStateRequest.Success += _ => setSubmissionTargetFromLatestOnlineState(); + break; + } + } + + private void setSubmissionTargetFromLatestOnlineState() + { + Debug.Assert(settings.LatestOnlineStateRequest != null); + settings.Target.Disabled = false; + settings.Target.Value = settings.LatestOnlineStateRequest.Response?.Status >= BeatmapOnlineStatus.Pending ? BeatmapSubmissionTarget.Pending : BeatmapSubmissionTarget.WIP; + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs new file mode 100644 index 0000000000..158b6bc02d --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.Edit.Submission +{ + public class SubmissionBeatmapExporter : LegacyBeatmapExporter + { + private readonly uint? beatmapSetId; + private readonly HashSet? allocatedBeatmapIds; + + public SubmissionBeatmapExporter(Storage storage, PutBeatmapSetResponse putBeatmapSetResponse) + : base(storage) + { + beatmapSetId = putBeatmapSetResponse.BeatmapSetId; + allocatedBeatmapIds = putBeatmapSetResponse.BeatmapIds.Select(id => (int)id).ToHashSet(); + } + + protected override void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap) + { + base.MutateBeatmap(beatmapSet, playableBeatmap); + + if (beatmapSetId != null && allocatedBeatmapIds != null) + { + playableBeatmap.BeatmapInfo.BeatmapSet = beatmapSet; + playableBeatmap.BeatmapInfo.BeatmapSet!.OnlineID = (int)beatmapSetId; + + if (allocatedBeatmapIds.Contains(playableBeatmap.BeatmapInfo.OnlineID)) + { + allocatedBeatmapIds.Remove(playableBeatmap.BeatmapInfo.OnlineID); + return; + } + + if (playableBeatmap.BeatmapInfo.OnlineID > 0) + throw new InvalidOperationException($@"Difficulty ""{playableBeatmap.BeatmapInfo.DifficultyName}"" has BeatmapID {playableBeatmap.BeatmapInfo.OnlineID} that has not been assigned to it by the server!"); + + if (allocatedBeatmapIds.Count == 0) + throw new InvalidOperationException(@"Ran out of new beatmap IDs to assign to unsubmitted beatmaps!"); + + int newId = allocatedBeatmapIds.First(); + allocatedBeatmapIds.Remove(newId); + playableBeatmap.BeatmapInfo.OnlineID = newId; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs new file mode 100644 index 0000000000..e7f8ff933d --- /dev/null +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -0,0 +1,292 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Submission +{ + public partial class SubmissionStageProgress : CompositeDrawable + { + public LocalisableString StageDescription { get; init; } + + public int StageIndex { get; init; } + + private Bindable status { get; } = new Bindable(); + + private Bindable progress { get; } = new Bindable(); + + private Container progressBarContainer = null!; + private Box progressBar = null!; + private Container iconContainer = null!; + private OsuTextFlowContainer errorMessage = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private Sample? progressSample; + + private const int stage_done_sample_count = 4; + private Sample? stageDoneSample; + + private Sample? errorSample; + private Sample? cancelSample; + + private SampleChannel? progressSampleChannel; + + private const int fadeout_duration = 100; + private ScheduledDelegate? progressSampleFadeDelegate; + private ScheduledDelegate? progressSampleStopDelegate; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, AudioManager audio) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = StageDescription, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + iconContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Children = + [ + progressBarContainer = new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Width = 150, + Height = 10, + CornerRadius = 5, + Masking = true, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + progressBar = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 0, + Colour = colourProvider.Highlight1, + } + } + }, + errorMessage = new OsuTextFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + // should really be `CentreRight` too, but that's broken due to a framework bug + // (https://github.com/ppy/osu-framework/issues/5084) + TextAnchor = Anchor.BottomRight, + Width = 450, + AutoSizeAxes = Axes.Y, + Alpha = 0, + Colour = colours.Red1, + } + ] + } + } + } + }; + + errorSample = audio.Samples.Get(@"UI/generic-error"); + cancelSample = audio.Samples.Get(@"UI/notification-cancel"); + progressSample = audio.Samples.Get(@"UI/bss-progress"); + + int stageSample = Math.Min(stage_done_sample_count - 1, StageIndex); + stageDoneSample = audio.Samples.Get(@$"UI/bss-stage-{stageSample}"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true); + progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true); + + progressSampleChannel = progressSample?.GetChannel(); + if (progressSampleChannel != null) + progressSampleChannel.ManualFree = true; + } + + public void SetNotStarted() => status.Value = StageStatusType.NotStarted; + + public void SetInProgress(float? progress = null) + { + this.progress.Value = progress; + status.Value = StageStatusType.InProgress; + + if (progressSampleChannel == null) + return; + + progressSampleChannel.Frequency.Value = 0.5f; + progressSampleChannel.Volume.Value = 0.25f; + progressSampleChannel.Looping = true; + } + + public void SetCompleted() => status.Value = StageStatusType.Completed; + + public void SetFailed(string reason) + { + status.Value = StageStatusType.Failed; + errorMessage.Text = reason; + } + + public void SetCanceled() => status.Value = StageStatusType.Canceled; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + progressSampleChannel?.Stop(); + progressSampleChannel?.Dispose(); + } + + private const float transition_duration = 200; + private const Easing transition_easing = Easing.OutQuint; + + private void updateProgress() + { + progressSampleFadeDelegate?.Cancel(); + progressSampleStopDelegate?.Cancel(); + + progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, transition_easing); + + if (progress.Value is float progressValue) + { + progressBar.ResizeWidthTo(progressValue, transition_duration, transition_easing); + + if (progressSampleChannel == null || Precision.AlmostEquals(progressValue, 0f)) + return; + + // Don't restart the looping sample if already playing + if (!progressSampleChannel.Playing) + progressSampleChannel.Play(); + + this.TransformBindableTo(progressSampleChannel.Frequency, 0.5f + (progressValue * 1.5f), transition_duration, transition_easing); + this.TransformBindableTo(progressSampleChannel.Volume, 0.25f + (progressValue * .75f), transition_duration, transition_easing); + + progressSampleFadeDelegate = Scheduler.AddDelayed(() => + { + // Perform a fade-out before stopping the sample to prevent clicking. + this.TransformBindableTo(progressSampleChannel.Volume, 0, fadeout_duration); + progressSampleStopDelegate = Scheduler.AddDelayed(() => { progressSampleChannel.Stop(); }, fadeout_duration); + }, transition_duration - fadeout_duration); + } + } + + private void updateStatus() + { + progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint); + errorMessage.FadeTo(status.Value == StageStatusType.Failed ? 1 : 0, transition_duration, Easing.OutQuint); + + iconContainer.Clear(); + iconContainer.ClearTransforms(); + + switch (status.Value) + { + case StageStatusType.InProgress: + iconContainer.Child = new LoadingSpinner + { + Size = new Vector2(16), + State = { Value = Visibility.Visible, }, + }; + iconContainer.Colour = colours.Orange1; + break; + + case StageStatusType.Completed: + iconContainer.Child = new SpriteIcon + { + Icon = FontAwesome.Solid.CheckCircle, + Size = new Vector2(16), + }; + iconContainer.Colour = colours.Green1; + iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + + // manually set progress value, as to trigger sample playback for the final section + progress.Value = 1; + + stageDoneSample?.Play(); + + break; + + case StageStatusType.Failed: + iconContainer.Child = new SpriteIcon + { + Icon = FontAwesome.Solid.ExclamationCircle, + Size = new Vector2(16), + }; + iconContainer.Colour = colours.Red1; + iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + errorSample?.Play(); + progressSampleChannel?.Stop(); + break; + + case StageStatusType.Canceled: + iconContainer.Child = new SpriteIcon + { + Icon = FontAwesome.Solid.Ban, + Size = new Vector2(16), + }; + iconContainer.Colour = colours.Gray8; + iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + cancelSample?.Play(); + progressSampleChannel?.Stop(); + break; + } + } + + public enum StageStatusType + { + NotStarted, + InProgress, + Completed, + Failed, + Canceled, + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs index 49e5b76dd6..86d8ac681f 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs @@ -20,6 +20,8 @@ namespace osu.Game.Screens.Edit.Timing { public partial class ControlPointList : CompositeDrawable { + public Action? SelectClosestTimingPoint { get; init; } + private ControlPointTable table = null!; private Container controls = null!; private OsuButton deleteButton = null!; @@ -34,6 +36,9 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private Bindable selectedGroup { get; set; } = null!; + [Resolved] + private IEditorChangeHandler? editorChangeHandler { get; set; } + [BackgroundDependencyLoader] private void load(OsuColour colours, OverlayColourProvider colourProvider) { @@ -72,7 +77,7 @@ namespace osu.Game.Screens.Edit.Timing new RoundedButton { Text = "Select closest to current time", - Action = goToCurrentGroup, + Action = SelectClosestTimingPoint, Size = new Vector2(220, 30), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -110,6 +115,9 @@ namespace osu.Game.Screens.Edit.Timing } }, }; + + if (editorChangeHandler != null) + editorChangeHandler.OnStateChange += onUndoRedo; } protected override void LoadComplete() @@ -140,17 +148,6 @@ namespace osu.Game.Screens.Edit.Timing table.Padding = new MarginPadding { Bottom = controls.DrawHeight }; } - private void goToCurrentGroup() - { - double accurateTime = clock.CurrentTimeAccurate; - - var activeTimingPoint = Beatmap.ControlPointInfo.TimingPointAt(accurateTime); - var activeEffectPoint = Beatmap.ControlPointInfo.EffectPointAt(accurateTime); - - double latestActiveTime = Math.Max(activeTimingPoint.Time, activeEffectPoint.Time); - selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(latestActiveTime); - } - private void delete() { if (selectedGroup.Value == null) @@ -185,5 +182,21 @@ namespace osu.Game.Screens.Edit.Timing selectedGroup.Value = group; } + + private void onUndoRedo() + { + // Best effort. We have no tracking of control points through undo/redo changes. + // If we don't deselect, things like offset changes could spawn groups to be added from previous states (see https://github.com/ppy/osu/issues/31098). + if (selectedGroup.Value != null && !Beatmap.ControlPointInfo.Groups.Contains(selectedGroup.Value)) + selectedGroup.Value = null; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorChangeHandler != null) + editorChangeHandler.OnStateChange -= onUndoRedo; + } } } diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index fd812cfe2b..a37674b104 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -21,6 +22,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Screens.Edit.Timing.RowAttributes; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Edit.Timing { @@ -177,7 +179,7 @@ namespace osu.Game.Screens.Edit.Timing private readonly BindableWithCurrent current = new BindableWithCurrent(); private Box background = null!; - private Box currentIndicator = null!; + private Drawable currentIndicator = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -202,7 +204,7 @@ namespace osu.Game.Screens.Edit.Timing { RelativeSizeAxes = Axes.Both; - InternalChildren = new Drawable[] + InternalChildren = new[] { background = new Box { @@ -210,11 +212,26 @@ namespace osu.Game.Screens.Edit.Timing Colour = colourProvider.Background1, Alpha = 0, }, - currentIndicator = new Box + currentIndicator = new Container { - RelativeSizeAxes = Axes.Y, - Width = 5, + RelativeSizeAxes = Axes.Both, Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Y, + Width = 5, + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Blending = BlendingParameters.Additive, + X = 5, + Width = 150, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.1f), Color4.White.Opacity(0)) + }, + } }, new Container { @@ -281,14 +298,8 @@ namespace osu.Game.Screens.Edit.Timing bool hasCurrentTimingPoint = activeTimingPoint.Value != null && current.Value.ControlPoints.Contains(activeTimingPoint.Value); bool hasCurrentEffectPoint = activeEffectPoint.Value != null && current.Value.ControlPoints.Contains(activeEffectPoint.Value); - if (IsHovered || isSelected) - background.FadeIn(100, Easing.OutQuint); - else if (hasCurrentTimingPoint || hasCurrentEffectPoint) - background.FadeTo(0.2f, 100, Easing.OutQuint); - else - background.FadeOut(100, Easing.OutQuint); - - background.Colour = isSelected ? colourProvider.Colour3 : colourProvider.Background1; + background.FadeTo(IsHovered || isSelected ? 1 : 0, 100, Easing.OutQuint); + background.FadeColour(isSelected ? colourProvider.Colour3 : colourProvider.Background1, 100, Easing.OutQuint); if (hasCurrentTimingPoint || hasCurrentEffectPoint) { diff --git a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs index 29e730c865..f91a67a7e3 100644 --- a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs @@ -7,18 +7,19 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Framework.Timing; 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.Utils; using osuTK; namespace osu.Game.Screens.Edit.Timing @@ -27,7 +28,7 @@ namespace osu.Game.Screens.Edit.Timing { private Container swing = null!; - private OsuSpriteText bpmText = null!; + private OsuTextFlowContainer bpmText = null!; private Drawable weight = null!; private Drawable stick = null!; @@ -41,6 +42,9 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private OverlayColourProvider overlayColourProvider { get; set; } = null!; + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } = null!; + public bool EnableClicking { get => metronomeTick.EnableClicking; @@ -209,10 +213,15 @@ namespace osu.Game.Screens.Edit.Timing }, } }, - bpmText = new OsuSpriteText + bpmText = new OsuTextFlowContainer(st => + { + st.Font = OsuFont.Default.With(fixedWidth: true); + st.Spacing = new Vector2(-1.9f, 0); + }) { Name = @"BPM display", Colour = overlayColourProvider.Content1, + AutoSizeAxes = Axes.Both, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Y = -3, @@ -222,21 +231,62 @@ namespace osu.Game.Screens.Edit.Timing Clock = new FramedClock(metronomeClock = new StopwatchClock(true)); } - private double beatLength; + private double effectiveBeatLength; + private double effectiveBpm; private TimingControlPoint timingPoint = null!; private bool isSwinging; - private readonly BindableInt interpolatedBpm = new BindableInt(); + private readonly BindableDouble interpolatedBpm = new BindableDouble(); private ScheduledDelegate? latchDelegate; + private bool spedUp; + + private int computeSpedUpDivisor() + { + if (!spedUp) + return 1; + + if (beatDivisor.Value % 3 == 0) + return 3; + if (beatDivisor.Value % 2 == 0) + return 2; + + return 1; + } + protected override void LoadComplete() { base.LoadComplete(); - interpolatedBpm.BindValueChanged(bpm => bpmText.Text = bpm.NewValue.ToLocalisableString()); + interpolatedBpm.BindValueChanged(_ => updateBpmText()); + } + + private void updateBpmText() + { + bool reachedFinalNumber = interpolatedBpm.Value == effectiveBpm; + int decimalPlaces = Math.Min(2, FormatUtils.FindPrecision((decimal)effectiveBpm)); + + string text = interpolatedBpm.Value.ToString($"N{decimalPlaces}"); + int? breakPoint = null; + + for (int i = 0; i < text.Length; i++) + { + if (!char.IsDigit(text[i])) + breakPoint = i; + } + + if (breakPoint != null) + { + bpmText.Text = text.Substring(0, breakPoint.Value); + bpmText.AddText(text.Substring(breakPoint.Value), cp => cp.Alpha = reachedFinalNumber ? 0.5f : 0.2f); + } + else + { + bpmText.Text = text; + } } protected override void Update() @@ -250,16 +300,20 @@ namespace osu.Game.Screens.Edit.Timing timingPoint = BeatSyncSource.ControlPoints.TimingPointAt(BeatSyncSource.Clock.CurrentTime); - if (beatLength != timingPoint.BeatLength) + Divisor = metronomeTick.Divisor = computeSpedUpDivisor(); + + if (effectiveBeatLength != timingPoint.BeatLength / Divisor) { - beatLength = timingPoint.BeatLength; + effectiveBeatLength = timingPoint.BeatLength / Divisor; + effectiveBpm = TimingSection.BeatLengthToBpm(effectiveBeatLength); EarlyActivationMilliseconds = timingPoint.BeatLength / 2; - float bpmRatio = (float)Interpolation.ApplyEasing(Easing.OutQuad, Math.Clamp((timingPoint.BPM - 30) / 480, 0, 1)); + float bpmRatio = (float)Interpolation.ApplyEasing(Easing.OutQuad, Math.Clamp((effectiveBpm - 30) / 480, 0, 1)); weight.MoveToY((float)Interpolation.Lerp(0.1f, 0.83f, bpmRatio), 600, Easing.OutQuint); - this.TransformBindableTo(interpolatedBpm, (int)Math.Round(timingPoint.BPM), 600, Easing.OutQuint); + + this.TransformBindableTo(interpolatedBpm, effectiveBpm, 300, Easing.OutExpo); } if (!BeatSyncSource.Clock.IsRunning && isSwinging) @@ -305,7 +359,7 @@ namespace osu.Game.Screens.Edit.Timing float currentAngle = swing.Rotation; float targetAngle = currentAngle > 0 ? -angle : angle; - swing.RotateTo(targetAngle, beatLength, Easing.InOutQuad); + swing.RotateTo(targetAngle, effectiveBeatLength, Easing.InOutQuad); } private void onTickPlayed() @@ -313,9 +367,25 @@ namespace osu.Game.Screens.Edit.Timing // Originally, this flash only occurred when the pendulum correctly passess the centre. // Mappers weren't happy with the metronome tick not playing immediately after starting playback // so now this matches the actual tick sample. - stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint); + stick.FlashColour(overlayColourProvider.Content1, effectiveBeatLength, Easing.OutQuint); } + protected override bool OnKeyDown(KeyDownEvent e) + { + updateDivisorFromKey(e); + + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + base.OnKeyUp(e); + + updateDivisorFromKey(e); + } + + private void updateDivisorFromKey(UIEvent e) => spedUp = e.ControlPressed; + private partial class MetronomeTick : BeatSyncedContainer { public bool EnableClicking; diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index 67d4429be8..e7bf798298 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,6 +16,8 @@ namespace osu.Game.Screens.Edit.Timing [Cached] public readonly Bindable SelectedGroup = new Bindable(); + private readonly Bindable currentEditorMode = new Bindable(); + [Resolved] private EditorClock? editorClock { get; set; } @@ -35,23 +38,53 @@ namespace osu.Game.Screens.Edit.Timing { new Drawable[] { - new ControlPointList(), + new ControlPointList + { + SelectClosestTimingPoint = selectClosestTimingPoint, + }, new ControlPointSettings(), }, } }; + [BackgroundDependencyLoader] + private void load(Editor? editor) + { + if (editor != null) + currentEditorMode.BindTo(editor.Mode); + } + protected override void LoadComplete() { base.LoadComplete(); - if (editorClock != null) + // When entering the timing screen, let's choose the closest valid timing point. + // This will emulate the osu-stable behaviour where a metronome and timing information + // are presented on entering the screen. + currentEditorMode.BindValueChanged(mode => { - // When entering the timing screen, let's choose the closest valid timing point. - // This will emulate the osu-stable behaviour where a metronome and timing information - // are presented on entering the screen. - var nearestTimingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime); - SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(nearestTimingPoint.Time); + if (mode.NewValue == EditorScreenMode.Timing) + selectClosestTimingPoint(); + }); + selectClosestTimingPoint(); + } + + private void selectClosestTimingPoint() + { + if (editorClock == null) + return; + + double accurateTime = editorClock.CurrentTimeAccurate; + + var activeTimingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(accurateTime); + var activeEffectPoint = EditorBeatmap.ControlPointInfo.EffectPointAt(accurateTime); + + if (activeEffectPoint.Equals(EffectControlPoint.DEFAULT)) + SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(activeTimingPoint.Time); + else + { + double latestActiveTime = Math.Max(activeTimingPoint.Time, activeEffectPoint.Time); + SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(latestActiveTime); } } diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs index ae1ac02dd6..0c06a4e69b 100644 --- a/osu.Game/Screens/Edit/Timing/TimingSection.cs +++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs @@ -115,7 +115,7 @@ namespace osu.Game.Screens.Edit.Timing try { if (double.TryParse(Current.Value, out double doubleVal) && doubleVal > 0) - beatLengthBindable.Value = beatLengthToBpm(doubleVal); + beatLengthBindable.Value = BeatLengthToBpm(doubleVal); } catch { @@ -130,7 +130,7 @@ namespace osu.Game.Screens.Edit.Timing beatLengthBindable.BindValueChanged(val => { - Current.Value = beatLengthToBpm(val.NewValue).ToString("N2"); + Current.Value = BeatLengthToBpm(val.NewValue).ToString("N2"); }, true); } @@ -146,6 +146,6 @@ namespace osu.Game.Screens.Edit.Timing } } - private static double beatLengthToBpm(double beatLength) => 60000 / beatLength; + public static double BeatLengthToBpm(double beatLength) => 60000 / beatLength; } } diff --git a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs index 45213b7bdb..a344483894 100644 --- a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs @@ -4,8 +4,8 @@ using System; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; @@ -305,7 +305,8 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private IBindable beatmap { get; set; } = null!; - private readonly IBindable track = new Bindable(); + [Resolved] + private EditorClock editorClock { get; set; } = null!; public WaveformRow(bool isMainRow) { @@ -313,13 +314,13 @@ namespace osu.Game.Screens.Edit.Timing } [BackgroundDependencyLoader] - private void load(EditorClock clock) + private void load() { InternalChildren = new Drawable[] { new Box { - Colour = colourProvider.Background3, + Colour = colourProvider.Background2, Alpha = isMainRow ? 1 : 0, RelativeSizeAxes = Axes.Both, }, @@ -343,17 +344,27 @@ namespace osu.Game.Screens.Edit.Timing Colour = colourProvider.Content2 } }; - - track.BindTo(clock.Track); } protected override void LoadComplete() { - track.ValueChanged += _ => waveformGraph.Waveform = beatmap.Value.Waveform; + editorClock.TrackChanged += updateWaveform; } - public int BeatIndex { set => beatIndexText.Text = value.ToString(); } - public Vector2 WaveformScale { set => waveformGraph.Scale = value; } + private void updateWaveform() + { + waveformGraph.Waveform = beatmap.Value.Waveform; + } + + public int BeatIndex + { + set => beatIndexText.Text = value.ToString(); + } + + public Vector2 WaveformScale + { + set => waveformGraph.Scale = value; + } public void WaveformOffsetTo(float value, bool animated) => this.TransformTo(nameof(waveformOffset), value, animated ? 300 : 0, Easing.OutQuint); @@ -363,6 +374,14 @@ namespace osu.Game.Screens.Edit.Timing get => waveformGraph.X; set => waveformGraph.X = value; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorClock.IsNotNull()) + editorClock.TrackChanged -= updateWaveform; + } } } } diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index de7b760bcd..8222e5633e 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -33,6 +33,9 @@ namespace osu.Game.Screens.Edit.Verify [Resolved] private VerifyScreen verify { get; set; } + [Resolved] + private BeatmapManager beatmapManager { get; set; } + private IBeatmapVerifier rulesetVerifier; private BeatmapVerifier generalVerifier; private BeatmapVerifierContext context; @@ -43,7 +46,13 @@ namespace osu.Game.Screens.Edit.Verify generalVerifier = new BeatmapVerifier(); rulesetVerifier = beatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapVerifier(); - context = new BeatmapVerifierContext(beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value); + context = BeatmapVerifierContext.Create( + beatmap, + workingBeatmap.Value, + verify.InterpretedDifficulty.Value, + beatmapManager + ); + verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue); RelativeSizeAxes = Axes.Both; @@ -85,6 +94,7 @@ namespace osu.Game.Screens.Edit.Verify base.LoadComplete(); verify.InterpretedDifficulty.BindValueChanged(_ => Refresh()); + verify.VerifyChecksScope.BindValueChanged(_ => Refresh()); verify.HiddenIssueTypes.BindCollectionChanged((_, _) => Refresh()); Refresh(); @@ -107,7 +117,9 @@ namespace osu.Game.Screens.Edit.Verify private IEnumerable filter(IEnumerable issues) { - return issues.Where(issue => !verify.HiddenIssueTypes.Contains(issue.Template.Type)); + return issues.Where(issue => + !verify.HiddenIssueTypes.Contains(issue.Template.Type) && + issue.Check.Metadata.Scope == verify.VerifyChecksScope.Value); } } } diff --git a/osu.Game/Screens/Edit/Verify/IssueSettings.cs b/osu.Game/Screens/Edit/Verify/IssueSettings.cs index 6d3c0520a2..01b41e622a 100644 --- a/osu.Game/Screens/Edit/Verify/IssueSettings.cs +++ b/osu.Game/Screens/Edit/Verify/IssueSettings.cs @@ -10,6 +10,7 @@ namespace osu.Game.Screens.Edit.Verify { protected override IReadOnlyList CreateSections() => new Drawable[] { + new ScopeSection(), new InterpretationSection(), new VisibilitySection() }; diff --git a/osu.Game/Screens/Edit/Verify/ScopeSection.cs b/osu.Game/Screens/Edit/Verify/ScopeSection.cs new file mode 100644 index 0000000000..51807d5a8f --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/ScopeSection.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Screens.Edit.Verify +{ + internal partial class ScopeSection : EditorRoundedScreenSettingsSection + { + protected override string HeaderText => "Scope"; + + [BackgroundDependencyLoader] + private void load(VerifyScreen verify) + { + Flow.Add(new SettingsEnumDropdown + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + TooltipText = "Select which type of checks to display", + Current = verify.VerifyChecksScope.GetBoundCopy() + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs index fe508860e0..208b33770f 100644 --- a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs +++ b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs @@ -19,6 +19,8 @@ namespace osu.Game.Screens.Edit.Verify public readonly Bindable InterpretedDifficulty = new Bindable(); + public readonly Bindable VerifyChecksScope = new Bindable(); + public readonly BindableList HiddenIssueTypes = new BindableList { IssueType.Negligible }; public IssueList IssueList { get; private set; } diff --git a/osu.Game/Screens/Footer/ScreenBackButton.cs b/osu.Game/Screens/Footer/ScreenBackButton.cs index bf29186bb1..481192088c 100644 --- a/osu.Game/Screens/Footer/ScreenBackButton.cs +++ b/osu.Game/Screens/Footer/ScreenBackButton.cs @@ -19,6 +19,19 @@ namespace osu.Game.Screens.Footer { public const float BUTTON_WIDTH = 240; + public sealed override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + // Ensure clicks in the corner of the screen still trigger the back button. + // Need to apply more than 1x inflation due to shear. + var inputRectangle = DrawRectangle.Inflate(new MarginPadding + { + Left = OsuGame.SCREEN_EDGE_MARGIN * 2, + Bottom = OsuGame.SCREEN_EDGE_MARGIN * 2, + }); + + return inputRectangle.Contains(ToLocalSpace(screenSpacePos)); + } + public ScreenBackButton() : base(BUTTON_WIDTH) { diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index ea32507ca0..5dbc7a55ab 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -23,30 +24,45 @@ namespace osu.Game.Screens.Footer { public partial class ScreenFooter : OverlayContainer { - private const int padding = 60; - private const float delay_per_button = 30; - private const double transition_duration = 400; + public ScreenBackButton BackButton { get; private set; } = null!; + + /// + /// Called when logo tracking begins, intended to bring the osu! logo to the frontmost visually. + /// + public Action? RequestLogoInFront { private get; init; } + + /// + /// The back button was pressed. + /// + public Action? BackButtonPressed { private get; init; } public const int HEIGHT = 50; + private const int padding = 60; + private const float delay_per_button = 30; + private const double transition_duration = 500; + + // Disable masking because it breaks due to the height of this container being less than the displayed content. + // The height being set as it is is required for transition purposes. + public override bool UpdateSubTreeMasking() => false; + private readonly List overlays = new List(); private Box background = null!; private FillFlowContainer buttonsFlow = null!; - private Container removedButtonsContainer = null!; + private Container overlayContentContainer = null!; + private Container hiddenButtonsContainer = null!; + private LogoTrackingContainer logoTrackingContainer = null!; + private IDisposable? logoTracking; + // TODO: This has some weird update logic local in this class, but it only works for overlay containers. + // This is not what we want. The footer is to be displayed on *screens* with different colour schemes. + // It needs to update on screen switch. + // + // For now it's locked to Blue to match song select (the most prominent usage). [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); - - [Resolved] - private OsuGame? game { get; set; } - - public ScreenBackButton BackButton { get; private set; } = null!; - - public Action? RequestLogoInFront { get; set; } - - public Action? OnBack; + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public ScreenFooter(BackReceptor? receptor = null) { @@ -71,27 +87,50 @@ namespace osu.Game.Screens.Footer RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background5 }, - buttonsFlow = new FillFlowContainer + new GridContainer { - Margin = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, - Y = 10f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7, 0), - AutoSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = OsuGame.SCREEN_EDGE_MARGIN + ScreenBackButton.BUTTON_WIDTH + padding }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + buttonsFlow = new FillFlowContainer + { + Name = "Visible footer buttons", + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Y = ScreenFooterButton.CORNER_RADIUS, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7, 0), + AutoSizeAxes = Axes.Both, + }, + overlayContentContainer = new Container + { + Name = "Overlay-provided extra content", + RelativeSizeAxes = Axes.Both, + Y = -OsuGame.SCREEN_EDGE_MARGIN, + }, + }, + } }, BackButton = new ScreenBackButton { - Margin = new MarginPadding { Bottom = 15f, Left = 12f }, + Margin = new MarginPadding { Bottom = OsuGame.SCREEN_EDGE_MARGIN, Left = OsuGame.SCREEN_EDGE_MARGIN }, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Action = onBackPressed, }, - removedButtonsContainer = new Container + hiddenButtonsContainer = new Container { - Margin = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, - Y = 10f, + Name = "Hidden footer buttons", + Margin = new MarginPadding { Left = OsuGame.SCREEN_EDGE_MARGIN + ScreenBackButton.BUTTON_WIDTH + padding }, + Y = ScreenFooterButton.CORNER_RADIUS, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, AutoSizeAxes = Axes.Both, @@ -115,28 +154,35 @@ namespace osu.Game.Screens.Footer changeLogoDepthDelegate?.Cancel(); changeLogoDepthDelegate = null; - logoTrackingContainer.StartTracking(logo, duration, easing); + logoTracking = logoTrackingContainer.StartTracking(logo, duration, easing); RequestLogoInFront?.Invoke(true); } public void StopTrackingLogo() { - logoTrackingContainer.StopTracking(); + logoTracking?.Dispose(); + logoTracking = null; - if (game != null) - changeLogoDepthDelegate = Scheduler.AddDelayed(() => RequestLogoInFront?.Invoke(false), transition_duration); + changeLogoDepthDelegate = Scheduler.AddDelayed(() => RequestLogoInFront?.Invoke(false), transition_duration); } protected override void PopIn() { + buttonsFlow.FadeIn(transition_duration / 4, Easing.OutQuint); + this.MoveToY(0, transition_duration, Easing.OutQuint) - .FadeIn(transition_duration, Easing.OutQuint); + .FadeIn(); } protected override void PopOut() { - this.MoveToY(HEIGHT, transition_duration, Easing.OutQuint) - .FadeOut(transition_duration, Easing.OutQuint); + // Really we shouldn't need to do this, but some buttons protrude vertically more than expected + // (see FooterButtonMods). + buttonsFlow.FadeOut(transition_duration, Easing.OutQuint); + + this.MoveToY(ScreenFooterButton.HEIGHT, transition_duration, Easing.OutQuint) + .Then() + .FadeOut(); } public void SetButtons(IReadOnlyList buttons) @@ -144,6 +190,7 @@ namespace osu.Game.Screens.Footer temporarilyHiddenButtons.Clear(); overlays.Clear(); + this.HidePopover(); clearActiveOverlayContainer(); var oldButtons = buttonsFlow.ToArray(); @@ -151,9 +198,10 @@ namespace osu.Game.Screens.Footer for (int i = 0; i < oldButtons.Length; i++) { var oldButton = oldButtons[i]; + oldButton.Enabled.Value = false; buttonsFlow.Remove(oldButton, false); - removedButtonsContainer.Add(oldButton); + hiddenButtonsContainer.Add(oldButton); if (buttons.Count > 0) makeButtonDisappearToRight(oldButton, i, oldButtons.Length, true); @@ -187,20 +235,21 @@ namespace osu.Game.Screens.Footer } } - private ShearedOverlayContainer? activeOverlay; - private Container? contentContainer; + public ShearedOverlayContainer? ActiveOverlay { get; private set; } + + private VisibilityContainer? activeOverlayContent; private readonly List temporarilyHiddenButtons = new List(); - public IDisposable RegisterActiveOverlayContainer(ShearedOverlayContainer overlay, out VisibilityContainer? footerContent) + public IDisposable RegisterActiveOverlayContainer(ShearedOverlayContainer overlay, out VisibilityContainer? overlayContent) { - if (activeOverlay != null) + if (ActiveOverlay != null) { throw new InvalidOperationException(@"Cannot set overlay content while one is already present. " + - $@"The previous overlay ({activeOverlay.GetType().Name}) should be hidden first."); + $@"The previous overlay ({ActiveOverlay.GetType().Name}) should be hidden first."); } - activeOverlay = overlay; + ActiveOverlay = overlay; Debug.Assert(temporarilyHiddenButtons.Count == 0); @@ -210,57 +259,67 @@ namespace osu.Game.Screens.Footer ? buttonsFlow.SkipWhile(b => b != targetButton).Skip(1) : buttonsFlow); - for (int i = 0; i < temporarilyHiddenButtons.Count; i++) - makeButtonDisappearToBottom(temporarilyHiddenButtons[i], 0, 0, false); + for (int i = temporarilyHiddenButtons.Count - 1; i >= 0; i--) + { + var button = temporarilyHiddenButtons[i]; + buttonsFlow.Remove(button, false); + hiddenButtonsContainer.Add(button); - var fallbackPosition = buttonsFlow.Any() - ? buttonsFlow.ToSpaceOfOtherDrawable(Vector2.Zero, this) - : BackButton.ToSpaceOfOtherDrawable(BackButton.LayoutRectangle.TopRight + new Vector2(5f, 0f), this); - - var targetPosition = targetButton?.ToSpaceOfOtherDrawable(targetButton.LayoutRectangle.TopRight, this) ?? fallbackPosition; + makeButtonDisappearToBottom(button, 0, 0, false); + } updateColourScheme(overlay.ColourProvider.Hue); - footerContent = overlay.CreateFooterContent(); + overlayContent = overlay.CreateFooterContent(); + activeOverlayContent = overlayContent; + var content = overlayContent; - var content = footerContent ?? Empty(); - - Add(contentContainer = new Container - { - Y = -15f, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = targetPosition.X }, - Child = content, - }); + if (content != null) + overlayContentContainer.Child = content; if (temporarilyHiddenButtons.Count > 0) - this.Delay(60).Schedule(() => content.Show()); + this.Delay(60).Schedule(() => content?.Show()); else - content.Show(); + content?.Show(); return new InvokeOnDisposal(clearActiveOverlayContainer); } private void clearActiveOverlayContainer() { - if (activeOverlay == null) + if (ActiveOverlay == null) return; - Debug.Assert(contentContainer != null); - contentContainer.Child.Hide(); + Debug.Assert(activeOverlayContent != null); + activeOverlayContent.Hide(); - double timeUntilRun = contentContainer.Child.LatestTransformEndTime - Time.Current; + double timeUntilRun = activeOverlayContent.LatestTransformEndTime - Time.Current; for (int i = 0; i < temporarilyHiddenButtons.Count; i++) - makeButtonAppearFromBottom(temporarilyHiddenButtons[i], 0); + { + var button = temporarilyHiddenButtons[i]; + hiddenButtonsContainer.Remove(button, false); + // temporarily bypass autosize on the X axis to prevent the buttons taking space + // immediately upon being moved back to the flow. + // this prevents the overlay content jumping to the right during its fade-out. + button.BypassAutoSizeAxes = Axes.X; + buttonsFlow.Add(button); + + makeButtonAppearFromBottom(button, 0); + } temporarilyHiddenButtons.Clear(); updateColourScheme(OverlayColourScheme.Aquamarine.GetHue()); - contentContainer.Delay(timeUntilRun).Expire(); - contentContainer = null; - activeOverlay = null; + activeOverlayContent.Delay(timeUntilRun).Schedule(() => + { + // overlay content is done displaying, re-enable autosize on all active buttons + foreach (var button in buttonsFlow) + button.BypassAutoSizeAxes = Axes.None; + }).Expire(); + activeOverlayContent = null; + ActiveOverlay = null; } private void updateColourScheme(int hue) @@ -287,6 +346,8 @@ namespace osu.Game.Screens.Footer private void showOverlay(OverlayContainer overlay) { + this.HidePopover(); + foreach (var o in overlays.Where(o => o != overlay)) o.Hide(); @@ -295,16 +356,16 @@ namespace osu.Game.Screens.Footer private void onBackPressed() { - if (activeOverlay != null) + if (ActiveOverlay != null) { - if (activeOverlay.OnBackButton()) + if (ActiveOverlay.OnBackButton()) return; - activeOverlay.Hide(); + ActiveOverlay.Hide(); return; } - OnBack?.Invoke(); + BackButtonPressed?.Invoke(); } public partial class BackReceptor : Drawable, IKeyBindingHandler diff --git a/osu.Game/Screens/Footer/ScreenFooterButton.cs b/osu.Game/Screens/Footer/ScreenFooterButton.cs index 6515203ca0..5d064670e7 100644 --- a/osu.Game/Screens/Footer/ScreenFooterButton.cs +++ b/osu.Game/Screens/Footer/ScreenFooterButton.cs @@ -25,16 +25,13 @@ namespace osu.Game.Screens.Footer { public partial class ScreenFooterButton : OsuClickableContainer, IKeyBindingHandler { - private const float shear = OsuGame.SHEAR; + public const int CORNER_RADIUS = 10; - protected const int CORNER_RADIUS = 10; - protected const int BUTTON_HEIGHT = 75; + public const int HEIGHT = 75; protected const int BUTTON_WIDTH = 116; public Bindable OverlayState = new Bindable(); - protected static readonly Vector2 BUTTON_SHEAR = new Vector2(shear, 0); - [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -57,9 +54,12 @@ namespace osu.Game.Screens.Footer public LocalisableString Text { + get => text.Text; set => text.Text = value; } + private readonly Container shearedContent; + private readonly SpriteText text; private readonly SpriteIcon icon; @@ -75,11 +75,11 @@ namespace osu.Game.Screens.Footer { Overlay = overlay; - Size = new Vector2(BUTTON_WIDTH, BUTTON_HEIGHT); + Size = new Vector2(BUTTON_WIDTH, HEIGHT); Children = new Drawable[] { - new Container + shearedContent = new Container { EdgeEffect = new EdgeEffectParameters { @@ -89,7 +89,7 @@ namespace osu.Game.Screens.Footer Colour = Colour4.Black.Opacity(0.25f), Offset = new Vector2(0, 2), }, - Shear = BUTTON_SHEAR, + Shear = OsuGame.SHEAR, Masking = true, CornerRadius = CORNER_RADIUS, RelativeSizeAxes = Axes.Both, @@ -108,7 +108,7 @@ namespace osu.Game.Screens.Footer { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Shear = -BUTTON_SHEAR, + Shear = -OsuGame.SHEAR, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { @@ -135,7 +135,7 @@ namespace osu.Game.Screens.Footer }, new Container { - Shear = -BUTTON_SHEAR, + Shear = -OsuGame.SHEAR, Anchor = Anchor.BottomCentre, Origin = Anchor.Centre, Y = -CORNER_RADIUS, @@ -172,8 +172,8 @@ namespace osu.Game.Screens.Footer FinishTransforms(true); } - // use Content for tracking input as some buttons might be temporarily hidden with DisappearToBottom, and they become hidden by moving Content away from screen. - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Content.ReceivePositionalInputAt(screenSpacePos); + // account for shear and buttons temporarily hidden with DisappearToBottom. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => shearedContent.ReceivePositionalInputAt(screenSpacePos); public GlobalAction? Hotkey; diff --git a/osu.Game/Screens/Footer/ScreenStackFooter.cs b/osu.Game/Screens/Footer/ScreenStackFooter.cs new file mode 100644 index 0000000000..807dcc3fe0 --- /dev/null +++ b/osu.Game/Screens/Footer/ScreenStackFooter.cs @@ -0,0 +1,220 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Footer +{ + public partial class ScreenStackFooter : CompositeDrawable + { + /// + /// Called when logo tracking begins, intended to bring the osu! logo to the frontmost visually. + /// + public Action? RequestLogoInFront { private get; init; } + + /// + /// The back button was pressed. + /// + public Action? BackButtonPressed { private get; init; } + + /// + /// The (legacy) back button. + /// + public readonly BackButton BackButton; + + /// + /// The footer. + /// + public readonly ScreenFooter Footer; + + /// + /// Whether the legacy back button is currently displayed. + /// + private readonly IBindable backButtonVisibility = new BindableBool(); + + private readonly ScreenStackTracker screenTracker; + + public ScreenStackFooter(ScreenStack screenStack, ScreenFooter.BackReceptor? backReceptor = null) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + BackButton = new BackButton(backReceptor) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Action = () => BackButtonPressed?.Invoke(), + }, + Footer = new ScreenFooter(backReceptor) + { + RequestLogoInFront = v => RequestLogoInFront?.Invoke(v), + BackButtonPressed = () => BackButtonPressed?.Invoke() + } + }; + + screenTracker = new ScreenStackTracker(screenStack); + screenTracker.ScreenChanged += onScreenChanged; + + backButtonVisibility.ValueChanged += onBackButtonVisibilityChanged; + } + + private void onScreenChanged(IScreen lastScreen, IScreen newScreen) + { + unbindScreen(lastScreen); + bindScreen(newScreen); + } + + private void onBackButtonVisibilityChanged(ValueChangedEvent visible) + { + if (visible.NewValue) + BackButton.Show(); + else + BackButton.Hide(); + } + + private void unbindScreen(IScreen screen) + { + if (screen is not OsuScreen osuScreen) + return; + + backButtonVisibility.UnbindFrom(osuScreen.BackButtonVisibility); + } + + private void bindScreen(IScreen screen) + { + if (screen is not OsuScreen osuScreen) + { + ((BindableBool)backButtonVisibility).Value = true; + + Footer.SetButtons([]); + Footer.Hide(); + return; + } + + if (osuScreen.ShowFooter) + { + // the legacy back button should never display while the new footer is in use, as it + // contains its own local back button. + ((BindableBool)backButtonVisibility).Value = false; + + Footer.Show(); + + if (osuScreen.IsLoaded) + updateFooterButtons(); + else + { + // ensure the current buttons are immediately disabled on screen change (so they can't be pressed). + Footer.SetButtons([]); + + osuScreen.OnLoadComplete += _ => updateFooterButtons(); + } + + void updateFooterButtons() + { + var buttons = osuScreen.CreateFooterButtons(); + + osuScreen.LoadComponentsAgainstScreenDependencies(buttons); + + Footer.SetButtons(buttons); + Footer.Show(); + } + } + else + { + backButtonVisibility.BindTo(osuScreen.BackButtonVisibility); + + Footer.SetButtons([]); + Footer.Hide(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + screenTracker.Dispose(); + } + + /// + /// Recursively represents a single screen stack and any nested subscreen stack. + /// + private class ScreenStackTracker : IDisposable + { + /// + /// Invoked when the leading screen changes. + /// + /// + /// This differs from and + /// because lastScreen and newScreen may be subscreens of the current screen stack. + ///
+ /// As such, no assumptions may be made as to the relation of screens to this entry's . + ///
+ public event ScreenChangedDelegate? ScreenChanged; + + /// + /// The screen stack tracked by this entry. + /// + private readonly ScreenStack stack; + + /// + /// An entry corresponding to the subscreen stack of the current screen, if any. + /// + private ScreenStackTracker? subScreenTracker; + + /// + /// The screen which should be bound to the screen footer - the most nested subscreen. + /// + private IScreen leadingScreen => subScreenTracker?.leadingScreen ?? stack.CurrentScreen; + + public ScreenStackTracker(ScreenStack stack) + { + this.stack = stack; + + stack.ScreenPushed += onParentScreenChanged; + stack.ScreenExited += onParentScreenChanged; + } + + private void onParentScreenChanged(IScreen lastScreen, IScreen newScreen) + { + // The screen which we will be UNBINDING from the screen footer later on. + IScreen lastLeadingScreen = subScreenTracker?.leadingScreen ?? lastScreen; + + // Subscreens are attached to a parent screen, so when the parent changes the subscreen must also. + subScreenTracker?.Dispose(); + subScreenTracker = null; + + // Check if we've switched to a screen that has a subscreen. + if (newScreen is IHasSubScreenStack newStack) + { + subScreenTracker = new ScreenStackTracker(newStack.SubScreenStack); + subScreenTracker.ScreenChanged += onSubScreenScreenChanged; + } + + ScreenChanged?.Invoke(lastLeadingScreen, leadingScreen); + } + + private void onSubScreenScreenChanged(IScreen lastScreen, IScreen newScreen) + { + ScreenChanged?.Invoke(lastScreen, newScreen); + } + + public void Dispose() + { + stack.ScreenPushed -= onParentScreenChanged; + stack.ScreenExited -= onParentScreenChanged; + + if (subScreenTracker != null) + { + subScreenTracker.ScreenChanged -= onSubScreenScreenChanged; + subScreenTracker.Dispose(); + } + } + } + } +} diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 9e474ed0c6..3e203d71c7 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -61,6 +61,17 @@ namespace osu.Game.Screens ///
bool HideMenuCursorOnNonMouseInput { get; } + /// + /// On mobile phones, this specifies whether this requires the device to be in portrait orientation. + /// Tablet devices are unaffected by this property. + /// + /// + /// By default, all screens in the game display in landscape orientation on phones. + /// Setting this to true will display this screen in portrait orientation instead, + /// and switch back to landscape when transitioning back to a regular non-portrait screen. + /// + bool RequiresPortraitOrientation { get; } + /// /// Whether overlays should be able to be opened when this screen is current. /// @@ -68,13 +79,15 @@ namespace osu.Game.Screens /// /// Whether the back button should be displayed in this screen. + /// Note that this property is ignored when is true. /// + // todo: make this work with footer. IBindable BackButtonVisibility { get; } /// /// The current for this screen. /// - IBindable Activity { get; } + Bindable Activity { get; } /// /// The amount of parallax to be applied while this screen is displayed. @@ -86,7 +99,7 @@ namespace osu.Game.Screens Bindable Ruleset { get; } /// - /// A list of footer buttons to be added to the game footer, or empty to display no buttons. + /// Buttons to be added to the game's footer toolbar. /// IReadOnlyList CreateFooterButtons(); diff --git a/osu.Game/Screens/Import/FileImportScreen.cs b/osu.Game/Screens/Import/FileImportScreen.cs index 1bdacae87f..bd35b8131e 100644 --- a/osu.Game/Screens/Import/FileImportScreen.cs +++ b/osu.Game/Screens/Import/FileImportScreen.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -29,6 +30,7 @@ namespace osu.Game.Screens.Import private TextFlowContainer currentFileText; private RoundedButton importButton; + private RoundedButton importAllButton; private const float duration = 300; private const float button_height = 50; @@ -74,7 +76,7 @@ namespace osu.Game.Screens.Import new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = button_height + button_vertical_margin * 2 }, + Padding = new MarginPadding { Bottom = button_height * 2 + button_vertical_margin * 3 }, Child = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, @@ -97,14 +99,27 @@ namespace osu.Game.Screens.Import }, importButton = new RoundedButton { - Text = "Import", + Text = "Import selected file", Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.X, Height = button_height, Width = 0.9f, - Margin = new MarginPadding { Vertical = button_vertical_margin }, + Margin = new MarginPadding { Bottom = button_height + button_vertical_margin * 2 }, Action = () => startImport(fileSelector.CurrentFile.Value?.FullName) + }, + + importAllButton = new RoundedButton + { + Text = "Import all files from directory", + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = button_height, + Width = 0.9f, + TooltipText = "Imports all osu files from selected directory", + Margin = new MarginPadding { Vertical = button_vertical_margin }, + Action = () => startDirectoryImport(fileSelector.CurrentPath.Value?.FullName) } } } @@ -131,10 +146,20 @@ namespace osu.Game.Screens.Import return base.OnExiting(e); } - private void directoryChanged(ValueChangedEvent _) + private void directoryChanged(ValueChangedEvent directoryChangedEvent) { // this should probably be done by the selector itself, but let's do it here for now. fileSelector.CurrentFile.Value = null; + + DirectoryInfo newDirectory = directoryChangedEvent.NewValue; + importAllButton.Enabled.Value = + // this will be `null` if the user clicked the "Computer" option (showing drives) + // handling that is difficult due to platform differences, and nobody sane wants that to work with the "import all" button anyway + newDirectory != null + // extra safety against various I/O errors (lack of access, deleted directory, etc.) + && newDirectory.Exists + // there must be at least one file in the current directory for the game to import (non-recursive) + && newDirectory.EnumerateFiles().Any(file => game.HandledExtensions.Contains(file.Extension)); } private void fileChanged(ValueChangedEvent selectedFile) @@ -143,14 +168,14 @@ namespace osu.Game.Screens.Import currentFileText.Text = selectedFile.NewValue?.Name ?? "Select a file"; } - private void startImport(string path) + private void startImport(params string[] paths) { - if (string.IsNullOrEmpty(path)) + if (paths.Length == 0) return; Task.Factory.StartNew(async () => { - await game.Import(path).ConfigureAwait(false); + await game.Import(paths).ConfigureAwait(false); // some files will be deleted after successful import, so we want to refresh the view. Schedule(() => @@ -160,5 +185,19 @@ namespace osu.Game.Screens.Import }); }, TaskCreationOptions.LongRunning); } + + private void startDirectoryImport(string path) + { + if (string.IsNullOrEmpty(path)) + return; + + // get only files that match extensions handled by the game + IEnumerable filesToImport = Directory.EnumerateFiles(path) + .Where(file => game.HandledExtensions.Contains(Path.GetExtension(file))); + if (!filesToImport.Any()) + return; + + startImport(filesToImport.ToArray()); + } } } diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index d71ee05b27..9e7ff80f7c 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shaders; @@ -15,6 +16,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Seasonal; using IntroSequence = osu.Game.Configuration.IntroSequence; namespace osu.Game.Screens @@ -37,6 +39,11 @@ namespace osu.Game.Screens private IntroScreen getIntroSequence() { + // Headless tests run too fast to load non-circles intros correctly. + // They will hit the "audio can't play" notification and cause random test failures. + if (SeasonalUIConfig.ENABLED && !DebugUtils.IsNUnitRunning) + return new IntroChristmas(createMainMenu); + if (introSequence == IntroSequence.Random) introSequence = (IntroSequence)RNG.Next(0, (int)IntroSequence.Random); diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 41920605b0..a73fafcffd 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -13,6 +13,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; @@ -46,6 +47,7 @@ namespace osu.Game.Screens.Menu public Action? OnSolo; public Action? OnSettings; public Action? OnMultiplayer; + public Action? OnMatchmaking; public Action? OnPlaylists; public Action? OnDailyChallenge; @@ -73,7 +75,8 @@ namespace osu.Game.Screens.Menu else { // We should stop tracking as the facade is now out of scope. - logoTrackingContainer.StopTracking(); + logoTracking?.Dispose(); + logoTracking = null; } } @@ -83,6 +86,7 @@ namespace osu.Game.Screens.Menu private readonly List buttonsTopLevel = new List(); private readonly List buttonsPlay = new List(); + private readonly List buttonsMulti = new List(); private readonly List buttonsEdit = new List(); private Sample? sampleBackToLogo; @@ -108,7 +112,19 @@ namespace osu.Game.Screens.Menu { Padding = new MarginPadding { Right = WEDGE_WIDTH }, }, - backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), (_, _) => State = ButtonSystemState.TopLevel) + backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), (_, _) => + { + switch (State) + { + case ButtonSystemState.Multi: + State = ButtonSystemState.Play; + break; + + default: + State = ButtonSystemState.TopLevel; + break; + } + }) { Padding = new MarginPadding { Right = WEDGE_WIDTH }, VisibleStateMin = ButtonSystemState.Play, @@ -136,28 +152,39 @@ namespace osu.Game.Screens.Menu { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), onMultiplayer, Key.M)); + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), (_, _) => State = ButtonSystemState.Multi, Key.M)); buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Tournament, new Color4(94, 63, 186, 255), onPlaylists, Key.L)); buttonsPlay.Add(new DailyChallengeButton(@"button-daily-select", new Color4(94, 63, 186, 255), onDailyChallenge, Key.D)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, Key.E) + buttonsMulti.Add(new MainMenuButton("lounge", @"button-default-select", FontAwesome.Solid.Couch, new Color4(94, 63, 186, 255), onMultiplayer, Key.L, Key.M) + { + Padding = new MarginPadding { Left = WEDGE_WIDTH } + }); + buttonsMulti.Add(new MainMenuButton("quick play", @"button-daily-select", FontAwesome.Solid.Bolt, new Color4(94, 63, 186, 255), onMatchmaking, Key.Q)); + buttonsMulti.ForEach(b => b.VisibleState = ButtonSystemState.Multi); + + buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, + Key.E) { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), (_, _) => OnEditSkin?.Invoke(), Key.S)); buttonsEdit.ForEach(b => b.VisibleState = ButtonSystemState.Edit); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M, Key.L) + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M, + Key.L) { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), (_, _) => State = ButtonSystemState.Edit, Key.E)); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B, Key.D)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B, + Key.D)); if (host.CanExit) buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), (_, e) => OnExit?.Invoke(e), Key.Q)); + buttonArea.AddRange(buttonsMulti); buttonArea.AddRange(buttonsPlay); buttonArea.AddRange(buttonsEdit); buttonArea.AddRange(buttonsTopLevel); @@ -190,6 +217,17 @@ namespace osu.Game.Screens.Menu OnMultiplayer?.Invoke(); } + private void onMatchmaking(MainMenuButton mainMenuButton, UIEvent uiEvent) + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + OnMatchmaking?.Invoke(); + } + private void onPlaylists(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) @@ -245,6 +283,15 @@ namespace osu.Game.Screens.Menu if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed || e.SuperPressed) return false; + if (e.Key >= Key.F1 && e.Key <= Key.F35) + return false; + + switch (e.Key) + { + case Key.Escape: + return false; + } + if (triggerInitialOsuLogo()) return true; @@ -305,6 +352,7 @@ namespace osu.Game.Screens.Menu case ButtonSystemState.Edit: case ButtonSystemState.Play: + case ButtonSystemState.Multi: StopSamplePlayback(); backButton.TriggerClick(); return true; @@ -317,6 +365,7 @@ namespace osu.Game.Screens.Menu public void StopSamplePlayback() { buttonsPlay.ForEach(button => button.StopSamplePlayback()); + buttonsMulti.ForEach(button => button.StopSamplePlayback()); buttonsTopLevel.ForEach(button => button.StopSamplePlayback()); logo?.StopSamplePlayback(); } @@ -340,6 +389,10 @@ namespace osu.Game.Screens.Menu buttonsPlay.First().TriggerClick(); return false; + case ButtonSystemState.Multi: + buttonsMulti.First().TriggerClick(); + return false; + case ButtonSystemState.Edit: buttonsEdit.First().TriggerClick(); return false; @@ -381,6 +434,7 @@ namespace osu.Game.Screens.Menu } private ScheduledDelegate? logoDelayedAction; + private IDisposable? logoTracking; private void updateLogoState(ButtonSystemState lastState = ButtonSystemState.Initial) { @@ -393,7 +447,8 @@ namespace osu.Game.Screens.Menu logoDelayedAction?.Cancel(); logoDelayedAction = Scheduler.AddDelayed(() => { - logoTrackingContainer.StopTracking(); + logoTracking?.Dispose(); + logoTracking = null; game?.Toolbar.Hide(); @@ -420,7 +475,8 @@ namespace osu.Game.Screens.Menu logo.ScaleTo(0.5f, 200, Easing.In); - logoTrackingContainer.StartTracking(logo, 200, Easing.In); + logoTracking?.Dispose(); + logoTracking = logoTrackingContainer.StartTracking(logo, 200, Easing.In); logoDelayedAction?.Cancel(); logoDelayedAction = Scheduler.AddDelayed(() => @@ -434,7 +490,10 @@ namespace osu.Game.Screens.Menu default: logo.ClearTransforms(targetMember: nameof(Position)); - logoTrackingContainer.StartTracking(logo, 0, Easing.In); + + logoTracking?.Dispose(); + logoTracking = logoTrackingContainer.StartTracking(logo, 0, Easing.In); + logo.ScaleTo(0.5f, 200, Easing.OutQuint); break; } @@ -442,7 +501,8 @@ namespace osu.Game.Screens.Menu break; case ButtonSystemState.EnteringMode: - logoTrackingContainer.StartTracking(logo, lastState == ButtonSystemState.Initial ? MainMenu.FADE_OUT_DURATION : 0, Easing.InSine); + logoTracking?.Dispose(); + logoTracking = logoTrackingContainer.StartTracking(logo, lastState == ButtonSystemState.Initial ? MainMenu.FADE_OUT_DURATION : 0, Easing.InSine); break; } } @@ -454,6 +514,7 @@ namespace osu.Game.Screens.Menu Initial, TopLevel, Play, + Multi, Edit, EnteringMode, } diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index 0dc54b321f..7b23cc7538 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -20,10 +20,12 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; using osu.Game.Skinning; @@ -169,11 +171,20 @@ namespace osu.Game.Screens.Menu if (s.Beatmaps.Count == 0) return; - initialBeatmap = beatmaps.GetWorkingBeatmap(s.Beatmaps.First()); + var working = beatmaps.GetWorkingBeatmap(s.Beatmaps.First()); + + // Ensure files area actually present on disk. + // This is to handle edge cases like users deleting files outside the game and breaking the world. + if (!hasAllFiles(working)) + return; + + initialBeatmap = working; }); return UsingThemedIntro = initialBeatmap != null; } + + AddInternal(new GlobalScrollAdjustsVolume()); } public override void OnEntering(ScreenTransitionEvent e) @@ -185,6 +196,20 @@ namespace osu.Game.Screens.Menu [Resolved] private INotificationOverlay notifications { get; set; } + private bool hasAllFiles(WorkingBeatmap working) + { + foreach (var f in working.BeatmapSetInfo.Files) + { + using (var str = working.GetStream(f.File.GetStoragePath())) + { + if (str == null) + return false; + } + } + + return true; + } + private void ensureEventuallyArrivingAtMenu() { // This intends to handle the case where an intro may get stuck. @@ -207,7 +232,7 @@ namespace osu.Game.Screens.Menu Text = NotificationsStrings.AudioPlaybackIssue }); } - }, 5000); + }, 8000); } public override void OnResuming(ScreenTransitionEvent e) diff --git a/osu.Game/Screens/Menu/KiaiMenuFountains.cs b/osu.Game/Screens/Menu/KiaiMenuFountains.cs index 07c06dcdb9..6e0351f922 100644 --- a/osu.Game/Screens/Menu/KiaiMenuFountains.cs +++ b/osu.Game/Screens/Menu/KiaiMenuFountains.cs @@ -3,10 +3,9 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Graphics; +using osu.Framework.Platform; using osu.Framework.Utils; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Menu @@ -16,12 +15,17 @@ namespace osu.Game.Screens.Menu private StarFountain leftFountain = null!; private StarFountain rightFountain = null!; + [Resolved] + private GameHost host { get; set; } = null!; + + private StarFountainSounds sounds = null!; + [BackgroundDependencyLoader] private void load() { RelativeSizeAxes = Axes.Both; - Children = new[] + Children = new Drawable[] { leftFountain = new StarFountain { @@ -35,32 +39,28 @@ namespace osu.Game.Screens.Menu Origin = Anchor.BottomRight, X = -250, }, + sounds = new StarFountainSounds() }; } private bool isTriggered; - private double? lastTrigger; - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + protected override void Update() { - base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + base.Update(); - if (effectPoint.KiaiMode && !isTriggered) + if (EffectPoint.KiaiMode && !isTriggered) { - bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - effectPoint.Time) < 500; + bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - EffectPoint.Time) < 500; if (isNearEffectPoint) Shoot(); } - isTriggered = effectPoint.KiaiMode; + isTriggered = EffectPoint.KiaiMode; } public void Shoot() { - if (lastTrigger != null && Clock.CurrentTime - lastTrigger < 500) - return; - int direction = RNG.Next(-1, 2); switch (direction) @@ -81,7 +81,9 @@ namespace osu.Game.Screens.Menu break; } - lastTrigger = Clock.CurrentTime; + // Don't play SFX when game is in background, as it can be a bit noisy. + if (host.IsActive.Value) + sounds.Play(); } } } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 0630b9612e..2296213dd6 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -13,34 +13,40 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.IO; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.SkinEditor; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; +using osu.Game.Seasonal; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Menu { - public partial class MainMenu : OsuScreen, IHandlePresentBeatmap, IKeyBindingHandler + public partial class MainMenu : OsuScreen, IHandlePresentBeatmap, IKeyBindingHandler, ISamplePlaybackDisabler { public const float FADE_IN_DURATION = 300; @@ -79,12 +85,17 @@ namespace osu.Game.Screens.Menu [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } + // used to stop kiai fountain samples when navigating to other screens + IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; + private readonly Bindable samplePlaybackDisabled = new Bindable(); + protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault(); protected override bool PlayExitSound => false; private Bindable holdDelay; private Bindable loginDisplayed; + private Bindable showMobileDisclaimer; private HoldToExitGameOverlay holdToExitGameOverlay; @@ -95,7 +106,7 @@ namespace osu.Game.Screens.Menu private SongTicker songTicker; private Container logoTarget; private OnlineMenuBanner onlineMenuBanner; - private MenuTip menuTip; + private MenuTipDisplay menuTipDisplay; private FillFlowContainer bottomElementsFlow; private SupporterDisplay supporterDisplay; @@ -104,11 +115,15 @@ namespace osu.Game.Screens.Menu [Resolved(canBeNull: true)] private SkinEditorOverlay skinEditor { get; set; } + [CanBeNull] + private IDisposable logoProxy; + [BackgroundDependencyLoader(true)] private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, OsuConfigManager config, SessionStatics statics, AudioManager audio) { holdDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); loginDisplayed = statics.GetBindable(Static.LoginOverlayDisplayed); + showMobileDisclaimer = config.GetBindable(OsuSetting.ShowMobileDisclaimer); if (host.CanExit) { @@ -124,6 +139,8 @@ namespace osu.Game.Screens.Menu AddRangeInternal(new[] { + SeasonalUIConfig.ENABLED ? new MainMenuSeasonalLighting() : Empty(), + new GlobalScrollAdjustsVolume(), buttonsContainer = new ParallaxContainer { ParallaxAmount = 0.01f, @@ -140,8 +157,9 @@ namespace osu.Game.Screens.Menu { skinEditor?.Show(); }, - OnSolo = loadSoloSongSelect, + OnSolo = loadSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), + OnMatchmaking = joinOrLeaveMatchmakingQueue, OnPlaylists = () => this.Push(new Playlists()), OnDailyChallenge = room => { @@ -159,14 +177,15 @@ namespace osu.Game.Screens.Menu } }, logoTarget = new Container { RelativeSizeAxes = Axes.Both, }, - sideFlashes = new MenuSideFlashes(), + sideFlashes = SeasonalUIConfig.ENABLED ? new SeasonalMenuSideFlashes() : new MenuSideFlashes(), songTicker = new SongTicker { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Margin = new MarginPadding { Right = 15, Top = 5 } }, - new KiaiMenuFountains(), + // For now, this is too much alongside the seasonal lighting. + SeasonalUIConfig.ENABLED ? Empty() : new KiaiMenuFountains(), bottomElementsFlow = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -176,7 +195,7 @@ namespace osu.Game.Screens.Menu Spacing = new Vector2(5), Children = new Drawable[] { - menuTip = new MenuTip + menuTipDisplay = new MenuTipDisplay { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -191,24 +210,26 @@ namespace osu.Game.Screens.Menu supporterDisplay = new SupporterDisplay { Margin = new MarginPadding(5), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, }, holdToExitGameOverlay?.CreateProxy() ?? Empty() }); + float baseDim = SeasonalUIConfig.ENABLED ? 0.84f : 1; + Buttons.StateChanged += state => { switch (state) { case ButtonSystemState.Initial: case ButtonSystemState.Exit: - ApplyToBackground(b => b.FadeColour(Color4.White, 500, Easing.OutSine)); + ApplyToBackground(b => b.FadeColour(OsuColour.Gray(baseDim), 500, Easing.OutSine)); onlineMenuBanner.State.Value = Visibility.Hidden; break; default: - ApplyToBackground(b => b.FadeColour(OsuColour.Gray(0.8f), 500, Easing.OutSine)); + ApplyToBackground(b => b.FadeColour(OsuColour.Gray(baseDim * 0.8f), 500, Easing.OutSine)); onlineMenuBanner.State.Value = Visibility.Visible; break; } @@ -220,9 +241,13 @@ namespace osu.Game.Screens.Menu reappearSampleSwoosh = audio.Samples.Get(@"Menu/reappear-swoosh"); } - public void ReturnToOsuLogo() => Buttons.State = ButtonSystemState.Initial; + protected override void LoadComplete() + { + base.LoadComplete(); + GetContainingInputManager(); + } - private void loadSoloSongSelect() => this.Push(new PlaySongSelect()); + public void ReturnToOsuLogo() => Buttons.State = ButtonSystemState.Initial; public override void OnEntering(ScreenTransitionEvent e) { @@ -246,7 +271,7 @@ namespace osu.Game.Screens.Menu } [CanBeNull] - private Drawable proxiedLogo; + private ScheduledDelegate mobileDisclaimerSchedule; protected override void LogoArriving(OsuLogo logo, bool resuming) { @@ -257,7 +282,7 @@ namespace osu.Game.Screens.Menu logo.FadeColour(Color4.White, 100, Easing.OutQuint); logo.FadeIn(100, Easing.OutQuint); - proxiedLogo = logo.ProxyToContainer(logoTarget); + logoProxy = logo.ProxyToContainer(logoTarget); if (resuming) { @@ -268,26 +293,46 @@ namespace osu.Game.Screens.Menu sideFlashes.Delay(FADE_IN_DURATION).FadeIn(64, Easing.InQuint); } - else if (!api.IsLoggedIn || api.State.Value == APIState.RequiresSecondFactorAuth) + else { // copy out old action to avoid accidentally capturing logo.Action in closure, causing a self-reference loop. var previousAction = logo.Action; - // we want to hook into logo.Action to display the login overlay, but also preserve the return value of the old action. + // we want to hook into logo.Action to display certain overlays, but also preserve the return value of the old action. // therefore pass the old action to displayLogin, so that it can return that value. // this ensures that the OsuLogo sample does not play when it is not desired. - logo.Action = () => displayLogin(previousAction); + logo.Action = () => onLogoClick(previousAction); } + } - bool displayLogin(Func originalAction) + private bool onLogoClick(Func originalAction) + { + if (showMobileDisclaimer.Value) { - if (!loginDisplayed.Value) + mobileDisclaimerSchedule?.Cancel(); + mobileDisclaimerSchedule = Scheduler.AddDelayed(() => { - Scheduler.AddDelayed(() => login?.Show(), 500); - loginDisplayed.Value = true; - } + dialogOverlay.Push(new MobileDisclaimerDialog(() => + { + showMobileDisclaimer.Value = false; + displayLoginIfApplicable(); + })); + }, 500); + } + else + displayLoginIfApplicable(); - return originalAction.Invoke(); + return originalAction.Invoke(); + } + + private void displayLoginIfApplicable() + { + if (loginDisplayed.Value) return; + + if (!api.IsLoggedIn || api.State.Value == APIState.RequiresSecondFactorAuth) + { + Scheduler.AddDelayed(() => login?.Show(), 500); + loginDisplayed.Value = true; } } @@ -296,11 +341,8 @@ namespace osu.Game.Screens.Menu var seq = logo.FadeOut(300, Easing.InSine) .ScaleTo(0.2f, 300, Easing.InSine); - if (proxiedLogo != null) - { - logo.ReturnProxy(); - proxiedLogo = null; - } + logoProxy?.Dispose(); + logoProxy = null; seq.OnComplete(_ => Buttons.SetOsuLogo(null)); seq.OnAbort(_ => Buttons.SetOsuLogo(null)); @@ -310,11 +352,8 @@ namespace osu.Game.Screens.Menu { base.LogoExiting(logo); - if (proxiedLogo != null) - { - logo.ReturnProxy(); - proxiedLogo = null; - } + logoProxy?.Dispose(); + logoProxy = null; } public override void OnSuspending(ScreenTransitionEvent e) @@ -334,6 +373,8 @@ namespace osu.Game.Screens.Menu supporterDisplay .FadeOut(500, Easing.OutQuint); + + samplePlaybackDisabled.Value = true; } public override void OnResuming(ScreenTransitionEvent e) @@ -349,11 +390,13 @@ namespace osu.Game.Screens.Menu musicController.EnsurePlayingSomething(); // Cycle tip on resuming - menuTip.ShowNextTip(); + menuTipDisplay.ShowNextTip(); bottomElementsFlow .ScaleTo(1, 1000, Easing.OutQuint) .FadeIn(1000, Easing.OutQuint); + + samplePlaybackDisabled.Value = false; } public override bool OnExiting(ScreenExitEvent e) @@ -413,7 +456,7 @@ namespace osu.Game.Screens.Menu Beatmap.Value = beatmap; Ruleset.Value = ruleset; - Schedule(loadSoloSongSelect); + Schedule(loadSongSelect); } public bool OnPressed(KeyBindingPressEvent e) @@ -436,5 +479,29 @@ namespace osu.Game.Screens.Menu public void OnReleased(KeyBindingReleaseEvent e) { } + + private void loadSongSelect() => this.Push(new SoloSongSelect()); + + private void joinOrLeaveMatchmakingQueue() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro()); + + private partial class MobileDisclaimerDialog : PopupDialog + { + public MobileDisclaimerDialog(Action confirmed) + { + HeaderText = ButtonSystemStrings.MobileDisclaimerHeader; + BodyText = ButtonSystemStrings.MobileDisclaimerBody; + + Icon = FontAwesome.Solid.SmileBeam; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = "Understood", + Action = confirmed, + }, + }; + } + } } } diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index f8824795d8..235babeed2 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -20,6 +20,7 @@ using osu.Game.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; using osu.Framework.Localisation; using osu.Game.Beatmaps.ControlPoints; @@ -257,6 +258,15 @@ namespace osu.Game.Screens.Menu protected override void OnMouseUp(MouseUpEvent e) { + // HORRIBLE HACK + // This is here so that on mobile, the main menu button that progresses to song select can correctly progress to song select v2 when held. + // Once the temporary solution of holding the button to access song select v2 is removed, this should be too. + // Without this, the long-press-to-right-click flow intercepts the hold and converts it to a right click which would not trigger the button + // and therefore not progress to song select. + if (e.Button == MouseButton.Right && e.CurrentState.Mouse.LastSource is ISourcedFromTouch) + trigger(e); + // END OF HORRIBLE HACK + boxHoverLayer.FadeTo(0, 1000, Easing.OutQuint); base.OnMouseUp(e); } diff --git a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs index f4e992be9a..f152c0c93c 100644 --- a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs +++ b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs @@ -1,21 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using osuTK.Graphics; -using osu.Game.Skinning; -using osu.Game.Online.API; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Screens.Menu { - internal partial class MenuLogoVisualisation : LogoVisualisation + public partial class MenuLogoVisualisation : LogoVisualisation { - private IBindable user; - private Bindable skin; + private IBindable user = null!; + private Bindable skin = null!; [BackgroundDependencyLoader] private void load(IAPIProvider api, SkinManager skinManager) @@ -23,11 +21,11 @@ namespace osu.Game.Screens.Menu user = api.LocalUser.GetBoundCopy(); skin = skinManager.CurrentSkin.GetBoundCopy(); - user.ValueChanged += _ => updateColour(); - skin.BindValueChanged(_ => updateColour(), true); + user.ValueChanged += _ => UpdateColour(); + skin.BindValueChanged(_ => UpdateColour(), true); } - private void updateColour() + protected virtual void UpdateColour() { if (user.Value?.IsSupporter ?? false) Colour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? Color4.White; diff --git a/osu.Game/Screens/Menu/MenuSideFlashes.cs b/osu.Game/Screens/Menu/MenuSideFlashes.cs index 533c39826c..426896825e 100644 --- a/osu.Game/Screens/Menu/MenuSideFlashes.cs +++ b/osu.Game/Screens/Menu/MenuSideFlashes.cs @@ -3,8 +3,10 @@ #nullable disable -using osuTK.Graphics; +using System; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -13,17 +15,19 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Skinning; using osu.Game.Online.API; -using System; -using osu.Framework.Audio.Track; -using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Screens.Menu { public partial class MenuSideFlashes : BeatSyncedContainer { + protected virtual bool RefreshColoursEveryFlash => false; + + protected virtual float Intensity => 2; + private readonly IBindable beatmap = new Bindable(); private Box leftBox; @@ -67,7 +71,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Y, - Width = box_width * 2, + Width = box_width * Intensity, Height = 1.5f, // align off-screen to make sure our edges don't become visible during parallax. X = -box_width, @@ -79,7 +83,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, RelativeSizeAxes = Axes.Y, - Width = box_width * 2, + Width = box_width * Intensity, Height = 1.5f, X = box_width, Alpha = 0, @@ -87,8 +91,11 @@ namespace osu.Game.Screens.Menu } }; - user.ValueChanged += _ => updateColour(); - skin.BindValueChanged(_ => updateColour(), true); + if (!RefreshColoursEveryFlash) + { + user.ValueChanged += _ => updateColour(); + skin.BindValueChanged(_ => updateColour(), true); + } } protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) @@ -104,18 +111,28 @@ namespace osu.Game.Screens.Menu private void flash(Drawable d, double beatLength, bool kiai, ChannelAmplitudes amplitudes) { - d.FadeTo(Math.Max(0, ((ReferenceEquals(d, leftBox) ? amplitudes.LeftChannel : amplitudes.RightChannel) - amplitude_dead_zone) / (kiai ? kiai_multiplier : alpha_multiplier)), box_fade_in_time) + if (RefreshColoursEveryFlash) + updateColour(); + + d.FadeTo(Math.Clamp(0.1f + ((ReferenceEquals(d, leftBox) ? amplitudes.LeftChannel : amplitudes.RightChannel) - amplitude_dead_zone) / (kiai ? kiai_multiplier : alpha_multiplier), 0.1f, 1), + box_fade_in_time) .Then() .FadeOut(beatLength, Easing.In); } - private void updateColour() + protected virtual Color4 GetBaseColour() { Color4 baseColour = colours.Blue; if (user.Value?.IsSupporter ?? false) baseColour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? baseColour; + return baseColour; + } + + private void updateColour() + { + var baseColour = GetBaseColour(); // linear colour looks better in this case, so let's use it for now. Color4 gradientDark = baseColour.Opacity(0).ToLinear(); Color4 gradientLight = baseColour.Opacity(0.6f).ToLinear(); diff --git a/osu.Game/Screens/Menu/MenuTip.cs b/osu.Game/Screens/Menu/MenuTip.cs deleted file mode 100644 index 3fc5fe57fb..0000000000 --- a/osu.Game/Screens/Menu/MenuTip.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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.Localisation; -using osu.Framework.Utils; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osuTK; -using osuTK.Graphics; -using osu.Game.Localisation; - -namespace osu.Game.Screens.Menu -{ - public partial class MenuTip : CompositeDrawable - { - [Resolved] - private OsuConfigManager config { get; set; } = null!; - - private LinkFlowContainer textFlow = null!; - - private Bindable showMenuTips = null!; - - [BackgroundDependencyLoader] - private void load() - { - AutoSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerExponent = 2.5f, - CornerRadius = 15, - Children = new Drawable[] - { - new Box - { - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - Alpha = 0.4f, - }, - } - }, - textFlow = new LinkFlowContainer - { - Width = 600, - AutoSizeAxes = Axes.Y, - TextAnchor = Anchor.TopCentre, - Spacing = new Vector2(0, 2), - Margin = new MarginPadding(10) - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - showMenuTips = config.GetBindable(OsuSetting.MenuTips); - showMenuTips.BindValueChanged(_ => ShowNextTip(), true); - } - - public void ShowNextTip() - { - if (!showMenuTips.Value) - { - this.FadeOut(100, Easing.OutQuint); - return; - } - - static void formatRegular(SpriteText t) => t.Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular); - static void formatSemiBold(SpriteText t) => t.Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold); - - var tip = getRandomTip(); - - textFlow.Clear(); - textFlow.AddParagraph(MenuTipStrings.MenuTipTitle, formatSemiBold); - textFlow.AddParagraph(tip, formatRegular); - - this.FadeInFromZero(200, Easing.OutQuint) - .Delay(1000 + 80 * tip.ToString().Length) - .Then() - .FadeOutFromOne(2000, Easing.OutQuint); - } - - private LocalisableString getRandomTip() - { - LocalisableString[] tips = - { - MenuTipStrings.ToggleToolbarShortcut, - MenuTipStrings.GameSettingsShortcut, - MenuTipStrings.DynamicSettings, - MenuTipStrings.NewFeaturesAreComingOnline, - MenuTipStrings.UIScalingSettings, - MenuTipStrings.ScreenScalingSettings, - MenuTipStrings.FreeOsuDirect, - MenuTipStrings.ReplaySeeking, - MenuTipStrings.MultithreadingSupport, - MenuTipStrings.TryNewMods, - MenuTipStrings.EmbeddedWebContent, - MenuTipStrings.BeatmapRightClick, - MenuTipStrings.TemporaryDeleteOperations, - MenuTipStrings.DiscoverPlaylists, - MenuTipStrings.ToggleAdvancedFPSCounter, - MenuTipStrings.GlobalStatisticsShortcut, - MenuTipStrings.ReplayPausing, - MenuTipStrings.ConfigurableHotkeys, - MenuTipStrings.PeekHUDWhenHidden, - MenuTipStrings.SkinEditor, - MenuTipStrings.DragAndDropImageInSkinEditor, - MenuTipStrings.ModPresets, - MenuTipStrings.ModCustomisationSettings, - MenuTipStrings.RandomSkinShortcut, - MenuTipStrings.ToggleReplaySettingsShortcut, - MenuTipStrings.CopyModsFromScore, - MenuTipStrings.AutoplayBeatmapShortcut - }; - - return tips[RNG.Next(0, tips.Length)]; - } - } -} diff --git a/osu.Game/Screens/Menu/MenuTipDisplay.cs b/osu.Game/Screens/Menu/MenuTipDisplay.cs new file mode 100644 index 0000000000..d9c90b069d --- /dev/null +++ b/osu.Game/Screens/Menu/MenuTipDisplay.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.Linq; +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.Localisation; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Input; +using osu.Game.Input.Bindings; +using osuTK; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Menu +{ + public partial class MenuTipDisplay : CompositeDrawable + { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private LinkFlowContainer textFlow = null!; + + private Bindable showMenuTips = null!; + + [Resolved] + private RealmKeyBindingStore keyBindingStore { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerExponent = 2.5f, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + Colour = Color4Extensions.FromHex("#171A1C"), + RelativeSizeAxes = Axes.Both, + Alpha = 0.75f, + }, + } + }, + textFlow = new LinkFlowContainer + { + Width = 600, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.TopCentre, + Spacing = new Vector2(0, 2), + Margin = new MarginPadding(10) + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + showMenuTips = config.GetBindable(OsuSetting.MenuTips); + showMenuTips.BindValueChanged(_ => ShowNextTip(), true); + } + + public void ShowNextTip() + { + if (!showMenuTips.Value) + { + this.FadeOut(100, Easing.OutQuint); + return; + } + + static void formatRegular(SpriteText t) => t.Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular); + + void formatSemiBold(SpriteText t) + { + t.Font = OsuFont.GetFont(Typeface.TorusAlternate, 16, weight: FontWeight.SemiBold); + t.Colour = colours.Pink0; + } + + var tip = getRandomTip(); + + textFlow.Clear(); + textFlow.AddIcon(FontAwesome.Solid.Lightbulb, icon => + { + icon.Colour = colours.Pink0; + icon.Size = new Vector2(16); + }); + textFlow.AddText(" "); + textFlow.AddText(MenuTipStrings.MenuTipTitle.ToSentence(), formatSemiBold); + textFlow.AddParagraph(tip, formatRegular); + + this + .FadeOut() + .ScaleTo(0.9f) + .Delay(600) + .FadeInFromZero(800, Easing.OutQuint) + .ScaleTo(1, 800, Easing.OutElasticHalf) + .Delay(1000 + 80 * tip.ToString().Length) + .Then() + .FadeOutFromOne(2000, Easing.OutQuint); + } + + private const int available_tips = 30; + + private LocalisableString getRandomTip() + { + int tipIndex = RNG.Next(0, available_tips); + + switch (tipIndex) + { + case 0: + return MenuTipStrings.ToggleToolbarShortcut( + keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.ToggleToolbar).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 1: + return MenuTipStrings.GameSettingsShortcut( + keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.ToggleSettings).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 2: + return MenuTipStrings.DynamicSettings; + + case 3: + return MenuTipStrings.NewFeaturesAreComingOnline; + + case 4: + return MenuTipStrings.UIScalingSettings; + + case 5: + return MenuTipStrings.ScreenScalingSettings; + + case 6: + return MenuTipStrings.FreeOsuDirect(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.ToggleBeatmapListing).FirstOrDefault() + ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 7: + return MenuTipStrings.ReplaySeeking; + + case 8: + return MenuTipStrings.MultithreadingSupport; + + case 9: + return MenuTipStrings.TryNewMods; + + case 10: + return MenuTipStrings.EmbeddedWebContent; + + case 11: + return MenuTipStrings.BeatmapRightClick; + + case 12: + return MenuTipStrings.TemporaryDeleteOperations; + + case 13: + return MenuTipStrings.DiscoverPlaylists; + + case 14: + return MenuTipStrings.ToggleAdvancedFPSCounter; + + case 15: + return MenuTipStrings.GlobalStatisticsShortcut; + + case 16: + return MenuTipStrings.ReplayPausing(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.TogglePauseReplay).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 17: + return MenuTipStrings.ConfigurableHotkeys; + + case 18: + return MenuTipStrings.PeekHUDWhenHidden(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.HoldForHUD).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 19: + return MenuTipStrings.SkinEditor(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.ToggleSkinEditor).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 20: + return MenuTipStrings.DragAndDropImageInSkinEditor; + + case 21: + return MenuTipStrings.ModPresets; + + case 22: + return MenuTipStrings.ModCustomisationSettings; + + case 23: + return MenuTipStrings.RandomSkinShortcut(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.RandomSkin).FirstOrDefault() ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 24: + return MenuTipStrings.ToggleReplaySettingsShortcut(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.ToggleReplaySettings).FirstOrDefault() + ?? InputSettingsStrings.ActionHasNoKeyBinding); + + case 25: + return MenuTipStrings.CopyModsFromScore; + + case 26: + return MenuTipStrings.AutoplayBeatmapShortcut; + + case 27: + return MenuTipStrings.LazerIsNotAWord; + + case 28: + return MenuTipStrings.RightMouseAbsoluteScroll; + + case 29: + return MenuTipStrings.ShiftClickInBeatmapOverlay; + } + + return string.Empty; + } + } +} diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index f2e2e25fa6..1b3317b12d 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -16,6 +17,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Backgrounds; @@ -53,8 +55,12 @@ namespace osu.Game.Screens.Menu private Sample sampleClick; private SampleChannel sampleClickChannel; - private Sample sampleBeat; - private Sample sampleDownbeat; + protected virtual MenuLogoVisualisation CreateMenuLogoVisualisation() => new MenuLogoVisualisation(); + + protected virtual double BeatSampleVariance => 0.1; + + protected Sample SampleBeat; + protected Sample SampleDownbeat; private readonly Container colourAndTriangles; private readonly TrianglesV2 triangles; @@ -151,15 +157,15 @@ namespace osu.Game.Screens.Menu AutoSizeAxes = Axes.Both, Children = new Drawable[] { - visualizer = new MenuLogoVisualisation + visualizer = CreateMenuLogoVisualisation().With(v => { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Alpha = visualizer_default_alpha, - Size = SCALE_ADJUST - }, - new Container + v.RelativeSizeAxes = Axes.Both; + v.Origin = Anchor.Centre; + v.Anchor = Anchor.Centre; + v.Alpha = visualizer_default_alpha; + v.Size = SCALE_ADJUST; + }), + LogoElements = new Container { AutoSizeAxes = Axes.Both, Children = new Drawable[] @@ -243,6 +249,8 @@ namespace osu.Game.Screens.Menu }; } + public Container LogoElements { get; private set; } + /// /// Schedule a new external animation. Handled queueing and finishing previous animations in a sane way. /// @@ -271,8 +279,9 @@ namespace osu.Game.Screens.Menu private void load(TextureStore textures, AudioManager audio) { sampleClick = audio.Samples.Get(@"Menu/osu-logo-select"); - sampleBeat = audio.Samples.Get(@"Menu/osu-logo-heartbeat"); - sampleDownbeat = audio.Samples.Get(@"Menu/osu-logo-downbeat"); + + SampleBeat = audio.Samples.Get(@"Menu/osu-logo-heartbeat"); + SampleDownbeat = audio.Samples.Get(@"Menu/osu-logo-downbeat"); logo.Texture = textures.Get(@"Menu/logo"); ripple.Texture = textures.Get(@"Menu/logo"); @@ -298,12 +307,13 @@ namespace osu.Game.Screens.Menu { if (beatIndex % timingPoint.TimeSignature.Numerator == 0) { - sampleDownbeat?.Play(); + SampleDownbeat?.Play(); } else { - var channel = sampleBeat.GetChannel(); - channel.Frequency.Value = 0.95 + RNG.NextDouble(0.1); + var channel = SampleBeat.GetChannel(); + + channel.Frequency.Value = 1 - BeatSampleVariance / 2 + RNG.NextDouble(BeatSampleVariance); channel.Play(); } }); @@ -383,12 +393,27 @@ namespace osu.Game.Screens.Menu protected override void OnMouseUp(MouseUpEvent e) { + // HORRIBLE HACK + // This is here so that on mobile, the logo can correctly progress from main menu to song select v2 when held. + // Once the temporary solution of holding the logo to access song select v2 is removed, this should be too. + // Without this, the long-press-to-right-click flow intercepts the hold and converts it to a right click which would not trigger the logo + // and therefore not progress to song select. + if (e.Button == MouseButton.Right && e.CurrentState.Mouse.LastSource is ISourcedFromTouch) + triggerClick(); + // END OF HORRIBLE HACK + if (e.Button != MouseButton.Left) return; logoBounceContainer.ScaleTo(1f, 500, Easing.OutElastic); } protected override bool OnClick(ClickEvent e) + { + triggerClick(); + return true; + } + + private void triggerClick() { flashLayer.ClearTransforms(); flashLayer.Alpha = 0.4f; @@ -400,8 +425,6 @@ namespace osu.Game.Screens.Menu sampleClickChannel = sampleClick.GetChannel(); sampleClickChannel.Play(); } - - return true; } protected override bool OnHover(HoverEvent e) @@ -450,7 +473,7 @@ namespace osu.Game.Screens.Menu public void StopSamplePlayback() => sampleClickChannel?.Stop(); - public Drawable ProxyToContainer(Container c) + public IDisposable ProxyToContainer(Container c) { if (currentProxyTarget != null) throw new InvalidOperationException("Previous proxy usage was not returned"); @@ -462,21 +485,19 @@ namespace osu.Game.Screens.Menu defaultProxyTarget.Remove(proxy, false); currentProxyTarget.Add(proxy); - return proxy; - } - public void ReturnProxy() - { - if (currentProxyTarget == null) - throw new InvalidOperationException("No usage to return"); + return new InvokeOnDisposal(returnProxy); - if (defaultProxyTarget == null) - throw new InvalidOperationException($"{nameof(SetupDefaultContainer)} must be called first"); + void returnProxy() + { + Debug.Assert(currentProxyTarget != null); + Debug.Assert(defaultProxyTarget != null); - currentProxyTarget.Remove(proxy, false); - currentProxyTarget = null; + currentProxyTarget.Remove(proxy, false); + currentProxyTarget = null; - defaultProxyTarget.Add(proxy); + defaultProxyTarget.Add(proxy); + } } public void SetupDefaultContainer(Container container) diff --git a/osu.Game/Screens/Menu/StarFountainSounds.cs b/osu.Game/Screens/Menu/StarFountainSounds.cs new file mode 100644 index 0000000000..842e718c48 --- /dev/null +++ b/osu.Game/Screens/Menu/StarFountainSounds.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.Framework.Threading; +using osu.Game.Audio; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Menu +{ + public partial class StarFountainSounds : CompositeComponent + { + private const int shoot_retrigger_delay = 500; + private const int loop_fade_duration = 500; + + private double? lastPlayback; + + private SkinnableSound shootSample = null!; + private PausableSkinnableSound loopSample = null!; + + private ScheduledDelegate? loopFadeDelegate; + private ScheduledDelegate? loopStopDelegate; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + shootSample = new SkinnableSound(new SampleInfo("Gameplay/fountain-shoot")), + loopSample = new PausableSkinnableSound(new SampleInfo("Gameplay/fountain-loop")) { Looping = true }, + }; + } + + public void Play() + { + loopFadeDelegate?.Cancel(); + loopStopDelegate?.Cancel(); + + try + { + // Only play 'shootSample' if enough time has passed since last `Play()` call. + if (lastPlayback == null || Time.Current - lastPlayback > shoot_retrigger_delay) + { + loopSample.Stop(); + shootSample.Play(); + return; + } + + // Only call `Play()` if `loopSample` is not already playing, to prevent restarting the sample each time. + if (!loopSample.RequestedPlaying) + { + this.TransformBindableTo(loopSample.Volume, 1); + loopSample.Play(); + } + + // Schedule a volume fadeout, followed by a `Stop()`. + loopFadeDelegate = Scheduler.AddDelayed(() => + { + this.TransformBindableTo(loopSample.Volume, 0, loop_fade_duration); + loopStopDelegate = Scheduler.AddDelayed(() => loopSample.Stop(), loop_fade_duration); + }, shoot_retrigger_delay); + } + finally + { + lastPlayback = Time.Current; + } + } + } +} diff --git a/osu.Game/Screens/Menu/SupporterDisplay.cs b/osu.Game/Screens/Menu/SupporterDisplay.cs index 6639300f4a..d33698a8a8 100644 --- a/osu.Game/Screens/Menu/SupporterDisplay.cs +++ b/osu.Game/Screens/Menu/SupporterDisplay.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; @@ -53,7 +54,7 @@ namespace osu.Game.Screens.Menu backgroundBox = new Box { RelativeSizeAxes = Axes.Both, - Alpha = 0.4f, + Alpha = 0.6f, }, supportFlow = new LinkFlowContainer { @@ -100,19 +101,18 @@ namespace osu.Game.Screens.Menu t.Padding = new MarginPadding { Left = 5, Top = 1 }; t.Font = t.Font.With(size: font_size); - t.Origin = Anchor.Centre; t.Colour = colours.Pink; Schedule(() => { - heart?.FlashColour(Color4.White, 750, Easing.OutQuint).Loop(); + heart.FlashColour(Color4.White, 750, Easing.OutQuint).Loop(); }); }); }, true); this .FadeOut() - .Delay(1000) + .Delay(RNG.Next(800, 4000)) .FadeInFromZero(800, Easing.OutQuint); scheduleDismissal(); @@ -129,13 +129,13 @@ namespace osu.Game.Screens.Menu protected override bool OnHover(HoverEvent e) { - backgroundBox.FadeTo(0.6f, 500, Easing.OutQuint); + backgroundBox.FadeTo(0.8f, 500, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - backgroundBox.FadeTo(0.4f, 500, Easing.OutQuint); + backgroundBox.FadeTo(0.6f, 500, Easing.OutQuint); base.OnHoverLost(e); } @@ -161,7 +161,7 @@ namespace osu.Game.Screens.Menu this .Delay(200) .FadeOut(750, Easing.Out); - }, 6000); + }, 8000); } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs deleted file mode 100644 index b213d424df..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs +++ /dev/null @@ -1,77 +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 System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Lounge.Components; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - /// - /// A that polls for the lounge listing. - /// - public partial class ListingPollingComponent : RoomPollingComponent - { - public IBindable InitialRoomsReceived => initialRoomsReceived; - private readonly Bindable initialRoomsReceived = new Bindable(); - - public readonly Bindable Filter = new Bindable(); - - [BackgroundDependencyLoader] - private void load() - { - Filter.BindValueChanged(_ => - { - RoomManager.ClearRooms(); - initialRoomsReceived.Value = false; - - if (IsLoaded) - PollImmediately(); - }); - } - - private GetRoomsRequest? lastPollRequest; - - protected override Task Poll() - { - if (!API.IsLoggedIn) - return base.Poll(); - - if (Filter.Value == null) - return base.Poll(); - - var tcs = new TaskCompletionSource(); - - lastPollRequest?.Cancel(); - - var req = new GetRoomsRequest(Filter.Value.Status, Filter.Value.Category); - - req.Success += result => - { - result = result.Where(r => r.Category != RoomCategory.DailyChallenge).ToList(); - - foreach (var existing in RoomManager.Rooms.ToArray()) - { - if (result.All(r => r.RoomID != existing.RoomID)) - RoomManager.RemoveRoom(existing); - } - - foreach (var incoming in result) - RoomManager.AddOrUpdateRoom(incoming); - - initialRoomsReceived.Value = true; - tcs.SetResult(true); - }; - - req.Failure += _ => tcs.SetResult(false); - - API.Queue(req); - - lastPollRequest = req; - return tcs.Task; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs index ef7c1747e9..4f9d1b9246 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs @@ -1,12 +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.Threading; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osuTK; using osuTK.Graphics; @@ -16,65 +23,57 @@ namespace osu.Game.Screens.OnlinePlay.Components public abstract partial class OnlinePlayBackgroundScreen : BackgroundScreen { private CancellationTokenSource? cancellationSource; - private PlaylistItemBackground? background; + private Background? lastBackground; + private int? beatmapId; [BackgroundDependencyLoader] private void load() { - switchBackground(new PlaylistItemBackground(playlistItem)); + loadNewBackground(); } - private PlaylistItem? playlistItem; - protected PlaylistItem? PlaylistItem { - get => playlistItem; set { - if (playlistItem == value) + if (beatmapId == value?.Beatmap.OnlineID) return; - playlistItem = value; + beatmapId = value?.Beatmap.OnlineID; - if (LoadState > LoadState.Ready) - updateBackground(); + if (LoadState >= LoadState.Ready) + loadNewBackground(); } } - private void updateBackground() + private void loadNewBackground() { - Schedule(() => + cancellationSource?.Cancel(); + cancellationSource = new CancellationTokenSource(); + + if (beatmapId == null) + switchBackground(new DefaultBackground()); + else + LoadComponentAsync(new OnlineBeatmapBackground(beatmapId.Value), switchBackground, cancellationSource.Token); + + void switchBackground(Background newBackground) { - var beatmap = playlistItem?.Beatmap; + float newDepth = 0; - string? lastCover = (background?.Beatmap?.BeatmapSet as IBeatmapSetOnlineInfo)?.Covers.Cover; - string? newCover = (beatmap?.BeatmapSet as IBeatmapSetOnlineInfo)?.Covers.Cover; + if (lastBackground != null) + { + newDepth = lastBackground.Depth + 1; + lastBackground.FinishTransforms(); + lastBackground.FadeOut(250); + lastBackground.Expire(); + } - if (lastCover == newCover) - return; + newBackground.Depth = newDepth; + newBackground.Colour = ColourInfo.GradientVertical(new Color4(0.1f, 0.1f, 0.1f, 1f), new Color4(0.4f, 0.4f, 0.4f, 1f)); + newBackground.BlurTo(new Vector2(10)); - 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(); + AddInternal(lastBackground = newBackground); } - - newBackground.Depth = newDepth; - newBackground.Colour = ColourInfo.GradientVertical(new Color4(0.1f, 0.1f, 0.1f, 1f), new Color4(0.4f, 0.4f, 0.4f, 1f)); - newBackground.BlurTo(new Vector2(10)); - - AddInternal(background = newBackground); } public override void OnSuspending(ScreenTransitionEvent e) @@ -89,5 +88,48 @@ namespace osu.Game.Screens.OnlinePlay.Components this.MoveToX(0); return result; } + + [LongRunningLoad] + private partial class OnlineBeatmapBackground : Background + { + private readonly int beatmapId; + + public OnlineBeatmapBackground(int beatmapId) + { + this.beatmapId = beatmapId; + } + + [BackgroundDependencyLoader] + private void load(BeatmapLookupCache lookupCache, LargeTextureStore textures, CancellationToken cancellationToken) + { + try + { + APIBeatmap? beatmap = lookupCache.GetBeatmapAsync(beatmapId, cancellationToken).GetResultSafely(); + string? coverImage = beatmap?.BeatmapSet?.Covers.Cover; + + if (coverImage != null) + Sprite.Texture = textures.Get(coverImage); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to retrieve cover image for beatmap {beatmapId}."); + } + } + } + + private partial class DefaultBackground : Background + { + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Sprite.Texture = beatmapManager.DefaultBeatmap.GetBackground(); + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs index d9cdcac7d7..6dfde183f0 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs @@ -53,13 +53,11 @@ namespace osu.Game.Screens.OnlinePlay.Components { RelativeSizeAxes = Axes.X, Height = 2, - Margin = new MarginPadding { Bottom = 2 } }, new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Top = 5 }, Spacing = new Vector2(10, 0), Children = new Drawable[] { diff --git a/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs b/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs deleted file mode 100644 index 6b06eaee1e..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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 partial class PlaylistItemBackground : Background - { - public readonly IBeatmapInfo? Beatmap; - - public PlaylistItemBackground(PlaylistItem? playlistItem) - { - Beatmap = playlistItem?.Beatmap; - } - - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps, LargeTextureStore textures) - { - Texture? texture = null; - - // prefer online cover where available. - if (Beatmap?.BeatmapSet is IBeatmapSetOnlineInfo online) - texture = textures.Get(online.Covers.Cover); - - Sprite.Texture = texture ?? beatmaps.DefaultBeatmap.GetBackground(); - } - - public override bool Equals(Background? other) - { - if (ReferenceEquals(null, other)) return false; - if (ReferenceEquals(this, other)) return true; - - return other.GetType() == GetType() - && ((PlaylistItemBackground)other).Beatmap == Beatmap; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index 2e669fd1b2..56e2719e9c 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -3,7 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online; @@ -11,7 +10,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public abstract partial class ReadyButton : RoundedButton, IHasTooltip + public abstract partial class ReadyButton : RoundedButton { public new readonly BindableBool Enabled = new BindableBool(); @@ -29,7 +28,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private void updateState() => base.Enabled.Value = availability.Value.State == DownloadState.LocallyAvailable && Enabled.Value; - public virtual LocalisableString TooltipText + public override LocalisableString TooltipText { get { diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs deleted file mode 100644 index 73f980f0a3..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.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 System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Development; -using osu.Framework.Graphics; -using osu.Framework.Logging; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public partial class RoomManager : Component, IRoomManager - { - public event Action? RoomsUpdated; - - private readonly BindableList rooms = new BindableList(); - - public IBindableList Rooms => rooms; - - protected IBindable JoinedRoom => joinedRoom; - private readonly Bindable joinedRoom = new Bindable(); - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - public RoomManager() - { - RelativeSizeAxes = Axes.Both; - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - PartRoom(); - } - - public virtual void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) - { - room.Host = api.LocalUser.Value; - - var req = new CreateRoomRequest(room); - - req.Success += result => - { - joinedRoom.Value = room; - - AddOrUpdateRoom(result); - room.CopyFrom(result); // Also copy back to the source model, since this is likely to have been stored elsewhere. - - // 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.Response?.Error ?? exception.Message); - }; - - api.Queue(req); - } - - private JoinRoomRequest? currentJoinRoomRequest; - - public virtual void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - { - currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = new JoinRoomRequest(room, password); - - currentJoinRoomRequest.Success += result => - { - joinedRoom.Value = room; - - AddOrUpdateRoom(result); - room.CopyFrom(result); // Also copy back to the source model, since this is likely to have been stored elsewhere. - - onSuccess?.Invoke(room); - }; - - currentJoinRoomRequest.Failure += exception => - { - if (exception is OperationCanceledException) - return; - - onError?.Invoke(exception.Message); - }; - - api.Queue(currentJoinRoomRequest); - } - - public virtual void PartRoom() - { - currentJoinRoomRequest?.Cancel(); - - if (joinedRoom.Value == null) - return; - - if (api.State.Value == APIState.Online) - api.Queue(new PartRoomRequest(joinedRoom.Value)); - - joinedRoom.Value = null; - } - - private readonly HashSet ignoredRooms = new HashSet(); - - public void AddOrUpdateRoom(Room room) - { - Debug.Assert(ThreadSafety.IsUpdateThread); - Debug.Assert(room.RoomID != null); - - if (ignoredRooms.Contains(room.RoomID.Value)) - return; - - try - { - var existing = rooms.FirstOrDefault(e => e.RoomID == room.RoomID); - if (existing == null) - rooms.Add(room); - else - existing.CopyFrom(room); - } - catch (Exception ex) - { - Logger.Error(ex, $"Failed to update room: {room.Name}."); - - ignoredRooms.Add(room.RoomID.Value); - rooms.Remove(room); - } - - notifyRoomsUpdated(); - } - - public void RemoveRoom(Room room) - { - Debug.Assert(ThreadSafety.IsUpdateThread); - - rooms.Remove(room); - notifyRoomsUpdated(); - } - - public void ClearRooms() - { - Debug.Assert(ThreadSafety.IsUpdateThread); - - rooms.Clear(); - notifyRoomsUpdated(); - } - - private void notifyRoomsUpdated() - { - Scheduler.AddOnce(invokeRoomsUpdated); - - void invokeRoomsUpdated() => RoomsUpdated?.Invoke(); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs deleted file mode 100644 index 0ba7f20f1c..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.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.Allocation; -using osu.Game.Online; -using osu.Game.Online.API; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public abstract partial class RoomPollingComponent : PollingComponent - { - [Resolved] - protected IAPIProvider API { get; private set; } = null!; - - [Resolved] - protected IRoomManager RoomManager { get; private set; } = null!; - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index 2bdb41ce12..f5b2bd018d 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using osu.Framework.Allocation; @@ -14,7 +15,6 @@ using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Online.Rooms; using osuTK; -using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Components { @@ -30,6 +30,8 @@ namespace osu.Game.Screens.OnlinePlay.Components private StarRatingDisplay maxDisplay = null!; private Drawable maxBackground = null!; + private BufferedContainer bufferedContent = null!; + public StarRatingRangeDisplay(Room room) { this.room = room; @@ -41,38 +43,43 @@ namespace osu.Game.Screens.OnlinePlay.Components { InternalChildren = new Drawable[] { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 1, - Children = new[] - { - minBackground = new Box - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f), - }, - maxBackground = new Box - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f), - }, - } - }, - new FillFlowContainer + new CircularContainer { AutoSizeAxes = Axes.Both, - Children = new Drawable[] + Masking = true, + // Stops artifacting from boxes drawn behind wrong colour boxes (and edge pixels adding up to higher opacity). + Padding = new MarginPadding(-0.1f), + Child = bufferedContent = new BufferedContainer(pixelSnapping: true, cachedFrameBuffer: true) { - minDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range), - maxDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range) + AutoSizeAxes = Axes.Both, + Children = new[] + { + minBackground = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1, 0.5f), + }, + maxBackground = new Box + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1, 0.5f), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + minDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range), + maxDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range) + } + } + } } - } + }, }; } @@ -109,7 +116,14 @@ namespace osu.Game.Screens.OnlinePlay.Components else { // When Playlist is not empty (in room) we compute actual range - var orderedDifficulties = room.Playlist.Select(p => p.Beatmap).OrderBy(b => b.StarRating).ToArray(); + IReadOnlyList difficultyRangeSource = room.Playlist.Where(item => !item.Expired).ToList(); + + if (difficultyRangeSource.Count == 0) + difficultyRangeSource = room.Playlist; + + var orderedDifficulties = difficultyRangeSource.Select(item => item.Beatmap) + .OrderBy(b => b.StarRating) + .ToArray(); minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0); maxDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[^1].StarRating : 0, 0); @@ -121,6 +135,8 @@ namespace osu.Game.Screens.OnlinePlay.Components minBackground.Colour = colours.ForStarDifficulty(minDifficulty.Stars); maxBackground.Colour = colours.ForStarDifficulty(maxDifficulty.Stars); + + bufferedContent.ForceRedraw(); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs index 2b1233506f..7147803412 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs @@ -29,18 +29,28 @@ namespace osu.Game.Screens.OnlinePlay.Components base.LoadComplete(); room.PropertyChanged += onRoomPropertyChanged; + + // Timed update required to track rooms which have hit the end time, see `HasEnded`. + Scheduler.AddDelayed(updateRoomStatus, 1000, true); updateRoomStatus(); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(Room.Status)) - updateRoomStatus(); + switch (e.PropertyName) + { + case nameof(Room.Category): + case nameof(Room.Status): + case nameof(Room.EndDate): + case nameof(Room.HasPassword): + updateRoomStatus(); + break; + } } private void updateRoomStatus() { - this.FadeColour(colours.ForRoomCategory(room.Category) ?? room.Status.GetAppropriateColour(colours), transitionDuration); + this.FadeColour(colours.ForRoomCategory(room.Category) ?? colours.ForRoomStatus(room), transitionDuration); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs index 13a282dd52..c3648a7edf 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs @@ -34,17 +34,16 @@ using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; +using osu.Game.Users; using osuTK; namespace osu.Game.Screens.OnlinePlay.DailyChallenge { - [Cached(typeof(IPreviewTrackOwner))] public partial class DailyChallenge : OsuScreen, IPreviewTrackOwner, IHandlePresentBeatmap { private readonly Room room; @@ -71,11 +70,8 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); - [Cached(Type = typeof(IRoomManager))] - private RoomManager roomManager { get; set; } - - [Cached] - private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly DailyChallengeBeatmapAvailabilityTracker beatmapAvailabilityTracker; [Resolved] private OsuGame? game { get; set; } @@ -111,12 +107,16 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge public override bool? ApplyModTrackAdjustments => true; + protected override UserActivity InitialActivity => new UserActivity.InDailyChallengeLobby(); + public DailyChallenge(Room room) { this.room = room; + playlistItem = room.Playlist.Single(); - roomManager = new RoomManager(); Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + + beatmapAvailabilityTracker = new DailyChallengeBeatmapAvailabilityTracker(playlistItem); } [BackgroundDependencyLoader] @@ -131,7 +131,6 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - roomManager, beatmapAvailabilityTracker, new ScreenStack(new RoomBackgroundScreen(playlistItem)) { @@ -161,7 +160,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { new Drawable[] { - new DrawableRoomPlaylistItem(playlistItem) + new DrawableRoomPlaylistItem(playlistItem, true) { RelativeSizeAxes = Axes.X, AllowReordering = false, @@ -381,7 +380,6 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { base.LoadComplete(); - beatmapAvailabilityTracker.SelectedItem.Value = playlistItem; beatmapAvailabilityTracker.Availability.BindValueChanged(_ => TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, playlistItem), true); userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); @@ -426,7 +424,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge base.OnEntering(e); waves.Show(); - roomManager.JoinRoom(room); + API.Queue(new JoinRoomRequest(room, null)); startLoopingTrack(this, musicController); metadataClient.BeginWatchingMultiplayerRoom(room.RoomID!.Value).ContinueWith(t => @@ -480,7 +478,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge previewTrackManager.StopAnyPlaying(this); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); - roomManager.PartRoom(); + API.Queue(new PartRoomRequest(room)); metadataClient.EndWatchingMultiplayerRoom(room.RoomID!.Value).FireAndForget(); return base.OnExiting(e); @@ -491,7 +489,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge if (!screen.IsCurrentScreen()) return; - var beatmap = beatmaps.QueryBeatmap(b => b.OnlineID == item.Beatmap.OnlineID); + var beatmap = beatmaps.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.Beatmap.OnlineID); screen.Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); // this will gracefully fall back to dummy beatmap if missing locally. screen.Ruleset.Value = rulesets.GetRuleset(item.RulesetID); @@ -532,7 +530,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge private void startPlay() { sampleStart?.Play(); - this.Push(new PlayerLoader(() => new PlaylistsPlayer(room, playlistItem) + this.Push(new PlayerLoader(() => new DailyChallengePlayer(room, playlistItem) { Exited = () => Scheduler.AddOnce(() => leaderboard.RefetchScores()) })); diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeBeatmapAvailabilityTracker.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeBeatmapAvailabilityTracker.cs new file mode 100644 index 0000000000..828a8d85ca --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeBeatmapAvailabilityTracker.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.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeBeatmapAvailabilityTracker : OnlinePlayBeatmapAvailabilityTracker + { + public DailyChallengeBeatmapAvailabilityTracker(PlaylistItem item) + { + PlaylistItem.Value = item; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs index 7fddb8d1c4..075d2af0aa 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs @@ -51,13 +51,13 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge private bool animationBegan; - private IBindable starDifficulty = null!; + private IBindable starDifficulty = null!; [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); - [Cached] - private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly DailyChallengeBeatmapAvailabilityTracker beatmapAvailabilityTracker; private bool shouldBePlayingMusic; @@ -91,6 +91,8 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge item = room.Playlist.Single(); ValidForResume = false; + + beatmapAvailabilityTracker = new DailyChallengeBeatmapAvailabilityTracker(item); } protected override BackgroundScreen CreateBackground() => new DailyChallengeIntroBackgroundScreen(colourProvider); @@ -114,7 +116,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Shear = new Vector2(OsuGame.SHEAR, 0f), + Shear = OsuGame.SHEAR, Children = new Drawable[] { titleContainer = new Container @@ -145,7 +147,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Origin = Anchor.Centre, Text = "Today's Challenge", Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), }, } @@ -171,7 +173,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Origin = Anchor.Centre, Text = room.Name.Split(':', StringSplitOptions.TrimEntries).Last(), Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), }, } @@ -244,7 +246,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, MaxWidth = horizontal_info_size, Text = beatmap.BeatmapSet!.Metadata.GetDisplayTitleRomanisable(false), Padding = new MarginPadding { Horizontal = 5f }, @@ -255,7 +257,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Text = $"Difficulty: {beatmap.DifficultyName}", Font = OsuFont.GetFont(size: 20, italics: true), MaxWidth = horizontal_info_size, - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, @@ -264,13 +266,13 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Text = $"by {beatmap.Metadata.Author.Username}", Font = OsuFont.GetFont(size: 16, italics: true), MaxWidth = horizontal_info_size, - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, starRatingDisplay = new StarRatingDisplay(default) { - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, Margin = new MarginPadding(5), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -299,7 +301,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, Current = { Value = item.RequiredMods.Select(m => m.ToMod(ruleset)).ToArray() @@ -314,11 +316,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge }; starDifficulty = difficultyCache.GetBindableDifficulty(beatmap); - starDifficulty.BindValueChanged(star => - { - if (star.NewValue != null) - starRatingDisplay.Current.Value = star.NewValue.Value; - }, true); + starDifficulty.BindValueChanged(star => starRatingDisplay.Current.Value = star.NewValue, true); LoadComponentAsync(new OnlineBeatmapSetCover(beatmap.BeatmapSet as IBeatmapSetOnlineInfo) { @@ -327,7 +325,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Origin = Anchor.Centre, FillMode = FillMode.Fit, Scale = new Vector2(1.2f), - Shear = new Vector2(-OsuGame.SHEAR, 0f), + Shear = -OsuGame.SHEAR, }, c => { beatmapBackground.Add(c); @@ -352,7 +350,6 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { base.OnEntering(e); - beatmapAvailabilityTracker.SelectedItem.Value = item; beatmapAvailabilityTracker.Availability.BindValueChanged(availability => { if (shouldBePlayingMusic && availability.NewValue.State == DownloadState.LocallyAvailable) diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs index 9fe2b70a5a..65805a970d 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs @@ -17,7 +17,7 @@ using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using osu.Game.Screens.SelectV2.Leaderboards; +using osu.Game.Screens.SelectV2; using osuTK; namespace osu.Game.Screens.OnlinePlay.DailyChallenge @@ -40,7 +40,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge private readonly Room room; private readonly PlaylistItem playlistItem; - private FillFlowContainer scoreFlow = null!; + private FillFlowContainer scoreFlow = null!; private Container userBestContainer = null!; private SectionHeader userBestHeader = null!; private LoadingLayer loadingLayer = null!; @@ -91,7 +91,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge new OsuScrollContainer { RelativeSizeAxes = Axes.Both, - Child = scoreFlow = new FillFlowContainer + Child = scoreFlow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -142,10 +142,10 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge request.Success += req => Schedule(() => { - var best = req.Scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo)).ToArray(); + var best = req.Scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, beatmap.Value.BeatmapInfo)).ToArray(); userBestScore.Value = req.UserScore; - var userBest = userBestScore.Value?.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo); + var userBest = userBestScore.Value?.CreateScoreInfo(scoreManager, rulesets, beatmap.Value.BeatmapInfo); cancellationTokenSource?.Cancel(); cancellationTokenSource = null; @@ -158,13 +158,23 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge } else { - LoadComponentsAsync(best.Select((s, index) => new LeaderboardScoreV2(s, sheared: false) + LoadComponentsAsync(best.Select((s, index) => { - Rank = index + 1, - IsPersonalBest = s.UserID == api.LocalUser.Value.Id, - Action = () => PresentScore?.Invoke(s.OnlineID), - SelectedMods = { BindTarget = SelectedMods }, - IsValidMod = IsValidMod, + BeatmapLeaderboardScore.HighlightType? highlightType = null; + + if (s.UserID == api.LocalUser.Value.Id) + highlightType = BeatmapLeaderboardScore.HighlightType.Own; + else if (api.LocalUserState.Friends.Any(r => r.TargetID == s.UserID)) + highlightType = BeatmapLeaderboardScore.HighlightType.Friend; + + return new BeatmapLeaderboardScore(s, sheared: false) + { + Rank = index + 1, + Highlight = highlightType, + Action = () => PresentScore?.Invoke(s.OnlineID), + SelectedMods = { BindTarget = SelectedMods }, + IsValidMod = IsValidMod, + }; }), loaded => { scoreFlow.Clear(); @@ -178,10 +188,10 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge if (userBest != null) { - userBestContainer.Add(new LeaderboardScoreV2(userBest, sheared: false) + userBestContainer.Add(new BeatmapLeaderboardScore(userBest, sheared: false) { Rank = userBest.Position, - IsPersonalBest = true, + Highlight = BeatmapLeaderboardScore.HighlightType.Own, Action = () => PresentScore?.Invoke(userBest.OnlineID), SelectedMods = { BindTarget = SelectedMods }, IsValidMod = IsValidMod, diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengePlayer.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengePlayer.cs new file mode 100644 index 0000000000..8097ce8b65 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengePlayer.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.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Users; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengePlayer : PlaylistsPlayer + { + protected override UserActivity InitialActivity => new UserActivity.PlayingDailyChallenge(Beatmap.Value.BeatmapInfo, Ruleset.Value); + + public DailyChallengePlayer(Room room, PlaylistItem playlistItem) + : base(room, playlistItem) + { + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index 207e0bdf55..423c956d1c 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs @@ -165,7 +165,8 @@ namespace osu.Game.Screens.OnlinePlay d.RequestDeletion = i => RequestDeletion?.Invoke(i); d.RequestResults = i => { - SelectedItem.Value = i; + if (AllowSelection) + SelectedItem.Value = i; RequestResults?.Invoke(i); }; d.RequestEdit = i => RequestEdit?.Invoke(i); @@ -204,7 +205,7 @@ namespace osu.Game.Screens.OnlinePlay ScrollContainer.ScrollIntoView(drawableItem); } - #region Key selection logic (shared with BeatmapCarousel and RoomsContainer) + #region Key selection logic (shared with BeatmapCarousel and RoomListing) public bool OnPressed(KeyBindingPressEvent e) { diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 7a773bb116..85a87b0dff 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -31,13 +31,13 @@ using osu.Game.Online.Chat; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; -using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; using osu.Game.Users.Drawables; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Screens.OnlinePlay { @@ -74,8 +74,9 @@ namespace osu.Game.Screens.OnlinePlay public bool IsSelectedItem => SelectedItem.Value?.ID == Item.ID; - private readonly DelayedLoadWrapper onScreenLoader = new DelayedLoadWrapper(Empty) { RelativeSizeAxes = Axes.Both }; + private readonly DelayedLoadWrapper onScreenLoader; private readonly IBindable valid = new Bindable(); + private readonly IBindable completed = new Bindable(); private IBeatmapInfo? beatmap; private IRulesetInfo? ruleset; @@ -120,15 +121,15 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(CanBeNull = true)] private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - public DrawableRoomPlaylistItem(PlaylistItem item) + public DrawableRoomPlaylistItem(PlaylistItem item, bool loadImmediately = false) : base(item) { + onScreenLoader = new DelayedLoadWrapper(Empty, timeBeforeLoad: loadImmediately ? 0 : 500) { RelativeSizeAxes = Axes.Both }; + Item = item; valid.BindTo(item.Valid); - - if (item.Expired) - Colour = OsuColour.Gray(0.5f); + completed.BindTo(item.Completed); } [BackgroundDependencyLoader] @@ -526,9 +527,27 @@ namespace osu.Game.Screens.OnlinePlay private IEnumerable createButtons() => new[] { - beatmap == null ? Empty() : new PlaylistDownloadButton(beatmap), + new CompletionIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Visible = { BindTarget = completed } + }, + beatmap == null + ? Empty().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }) + : new PlaylistDownloadButton(beatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, showResultsButton = new GrayButton(FontAwesome.Solid.ChartPie) { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Size = new Vector2(30, 30), Action = () => RequestResults?.Invoke(Item), Alpha = AllowShowingResults ? 1 : 0, @@ -536,13 +555,17 @@ namespace osu.Game.Screens.OnlinePlay }, editButton = new PlaylistEditButton { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Size = new Vector2(30, 30), Alpha = AllowEditing ? 1 : 0, Action = () => RequestEdit?.Invoke(Item), - TooltipText = CommonStrings.ButtonsEdit + TooltipText = Resources.Localisation.Web.CommonStrings.ButtonsEdit }, removeButton = new PlaylistRemoveButton { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Size = new Vector2(30, 30), Alpha = AllowDeletion ? 1 : 0, Action = () => RequestDeletion?.Invoke(Item), @@ -769,5 +792,64 @@ namespace osu.Game.Screens.OnlinePlay this.allowInteraction = allowInteraction; } } + + private partial class CompletionIcon : CompositeDrawable, IHasTooltip + { + public readonly BindableBool Visible = new BindableBool(); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(16), + Masking = true, + Colour = colours.Lime0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.5f), + Colour = OsuColour.Gray(0.5f), + Icon = FontAwesome.Solid.Check + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Visible.BindValueChanged(onVisibleChanged, true); + } + + private void onVisibleChanged(ValueChangedEvent visible) + { + if (visible.NewValue) + { + Size = new Vector2(16); + Alpha = 1; + } + else + { + Size = Vector2.Zero; + Alpha = 0; + } + } + + public LocalisableString TooltipText => DrawableRoomPlaylistItemStrings.CompletedTooltip; + } } } diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index dd6536cf26..7c632d1619 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -11,33 +11,29 @@ 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.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; using osuTK; namespace osu.Game.Screens.OnlinePlay { - public partial class FooterButtonFreeMods : FooterButton, IHasCurrentValue> + public partial class FooterButtonFreeMods : FooterButton { - private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); + public readonly Bindable> FreeMods = new Bindable>(); + public readonly IBindable Freestyle = new Bindable(); - public Bindable> Current + protected override bool IsActive => FreeMods.Value.Count > 0; + + public new Action Action { - get => current.Current; - set - { - ArgumentNullException.ThrowIfNull(value); - - current.Current = value; - } + set => throw new NotSupportedException("The click action is handled by the button itself."); } private OsuSpriteText count = null!; - private Circle circle = null!; private readonly FreeModSelectOverlay freeModSelectOverlay; @@ -45,6 +41,9 @@ namespace osu.Game.Screens.OnlinePlay public FooterButtonFreeMods(FreeModSelectOverlay freeModSelectOverlay) { this.freeModSelectOverlay = freeModSelectOverlay; + + // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. + base.Action = toggleAllFreeMods; } [Resolved] @@ -84,6 +83,7 @@ namespace osu.Game.Screens.OnlinePlay Origin = Anchor.Centre, Scale = new Vector2(0.8f), Icon = FontAwesome.Solid.Bars, + Enabled = { BindTarget = Enabled }, Action = () => freeModSelectOverlay.ToggleVisibility() } }); @@ -91,16 +91,16 @@ namespace osu.Game.Screens.OnlinePlay SelectedColour = colours.Yellow; DeselectedColour = SelectedColour.Opacity(0.5f); Text = @"freemods"; + + TooltipText = MultiplayerMatchStrings.FreeModsButtonTooltip; } protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(_ => updateModDisplay(), true); - - // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. - Action = toggleAllFreeMods; + Freestyle.BindValueChanged(_ => updateModDisplay()); + FreeMods.BindValueChanged(_ => updateModDisplay(), true); } /// @@ -110,16 +110,16 @@ namespace osu.Game.Screens.OnlinePlay { var availableMods = allAvailableAndValidMods.ToArray(); - Current.Value = Current.Value.Count == availableMods.Length + FreeMods.Value = FreeMods.Value.Count == availableMods.Length ? Array.Empty() : availableMods; } private void updateModDisplay() { - int currentCount = Current.Value.Count; + int currentCount = FreeMods.Value.Count; - if (currentCount == allAvailableAndValidMods.Count()) + if (currentCount == allAvailableAndValidMods.Count() || Freestyle.Value) { count.Text = "all"; count.FadeColour(colours.Gray2, 200, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs new file mode 100644 index 0000000000..c4edcec976 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyle.cs @@ -0,0 +1,101 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Screens.Select; + +namespace osu.Game.Screens.OnlinePlay +{ + public partial class FooterButtonFreestyle : FooterButton + { + public readonly Bindable Freestyle = new Bindable(); + + protected override bool IsActive => Freestyle.Value; + + public new Action Action + { + set => throw new NotSupportedException("The click action is handled by the button itself."); + } + + private OsuSpriteText text = null!; + private Circle circle = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public FooterButtonFreestyle() + { + // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. + base.Action = () => Freestyle.Value = !Freestyle.Value; + } + + [BackgroundDependencyLoader] + private void load() + { + ButtonContentContainer.AddRange(new[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + circle = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(5), + UseFullGlyphHeight = false, + } + } + } + }); + + SelectedColour = colours.Yellow; + DeselectedColour = SelectedColour.Opacity(0.5f); + Text = @"freestyle"; + + TooltipText = MultiplayerMatchStrings.FreestyleButtonTooltip; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Freestyle.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + if (Freestyle.Value) + { + text.Text = "on"; + text.FadeColour(colours.Gray2, 200, Easing.OutQuint); + circle.FadeColour(colours.Yellow, 200, Easing.OutQuint); + } + else + { + text.Text = "off"; + text.FadeColour(colours.GrayF, 200, Easing.OutQuint); + circle.FadeColour(colours.Gray4, 200, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Header.cs b/osu.Game/Screens/OnlinePlay/Header.cs index 860042fd37..825f809397 100644 --- a/osu.Game/Screens/OnlinePlay/Header.cs +++ b/osu.Game/Screens/OnlinePlay/Header.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay { public partial class Header : Container { - public const float HEIGHT = 80; + public const float HEIGHT = 50; private readonly ScreenStack? stack; private readonly MultiHeaderTitle title; diff --git a/osu.Game/Screens/OnlinePlay/IRoomManager.cs b/osu.Game/Screens/OnlinePlay/IRoomManager.cs deleted file mode 100644 index ed4fb7b15e..0000000000 --- a/osu.Game/Screens/OnlinePlay/IRoomManager.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay -{ - [Cached(typeof(IRoomManager))] - public interface IRoomManager - { - /// - /// Invoked when the s have been updated. - /// - event Action RoomsUpdated; - - /// - /// 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); - - /// - /// Joins a . - /// - /// The to join. must be populated. - /// An optional password to use for the join operation. - /// - /// - void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null); - - /// - /// Parts the currently-joined . - /// - void PartRoom(); - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs index 5bcc974c26..135b2b4db2 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -23,10 +23,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public partial class DrawableRoomParticipantsList : CompositeDrawable { - public const float SHEAR_WIDTH = 12f; - private const float avatar_size = 36; - private const float height = 60f; - private static readonly Vector2 shear = new Vector2(SHEAR_WIDTH / height, 0); + private const float avatar_size = 30; + private const float height = 40f; private readonly Room room; @@ -54,7 +52,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components RelativeSizeAxes = Axes.Both, Masking = true, CornerRadius = 10, - Shear = shear, + Shear = OsuGame.SHEAR, Child = new Box { RelativeSizeAxes = Axes.Both, @@ -71,10 +69,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, - Spacing = new Vector2(8), + Spacing = new Vector2(4), Padding = new MarginPadding { - Left = 8, + Left = 4, Right = 16 }, Children = new Drawable[] @@ -84,7 +82,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, - hostText = new LinkFlowContainer + hostText = new LinkFlowContainer(s => s.Font = OsuFont.Style.Caption2) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -103,7 +101,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components RelativeSizeAxes = Axes.Both, Masking = true, CornerRadius = 10, - Shear = shear, + Shear = OsuGame.SHEAR, Child = new Box { RelativeSizeAxes = Axes.Both, @@ -128,12 +126,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(16), + Size = new Vector2(12), Icon = FontAwesome.Solid.User, }, totalCount = new OsuSpriteText { - Font = OsuFont.Default.With(weight: FontWeight.Bold), + Font = OsuFont.Style.Caption2.With(weight: FontWeight.Bold), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs index 3b03ce61f1..4a98efb225 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Online.Rooms; @@ -62,7 +63,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Date = room.EndDate ?? DateTimeOffset.Now.AddYears(1); } - protected override string Format() + protected override LocalisableString Format() { if (room.EndDate == null) return string.Empty; @@ -70,7 +71,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components var diffToNow = Date.Subtract(DateTimeOffset.Now); if (diffToNow.TotalSeconds < -5) - return $"Closed {base.Format()}"; + return LocalisableString.Interpolate($"Closed {base.Format()}"); if (diffToNow.TotalSeconds < 0) return "Closed"; @@ -78,7 +79,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components if (diffToNow.TotalSeconds < 5) return "Closing soon"; - return $"Closing {base.Format()}"; + return LocalisableString.Interpolate($"Closing {base.Format()}"); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs index 0f63718355..121dffde1f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs @@ -8,7 +8,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public class FilterCriteria { public string SearchString = string.Empty; - public RoomStatusFilter Status; + public RoomModeFilter Mode; + public RoomStatusFilter? Status; public string Category = string.Empty; public RulesetInfo? Ruleset; public RoomPermissionsFilter Permissions; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/FreestyleStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/FreestyleStatusPill.cs new file mode 100644 index 0000000000..b306e27f84 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/FreestyleStatusPill.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.ComponentModel; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Online.Rooms; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public partial class FreestyleStatusPill : OnlinePlayPill + { + private readonly Room room; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); + + public FreestyleStatusPill(Room room) + { + this.room = room; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Pill.Background.Alpha = 1; + Pill.Background.Colour = colours.Yellow; + + TextFlow.Text = "Freestyle"; + TextFlow.Colour = Color4.Black; + + room.PropertyChanged += onRoomPropertyChanged; + updateFreestyleStatus(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.CurrentPlaylistItem): + case nameof(Room.Playlist): + updateFreestyleStatus(); + break; + } + } + + private void updateFreestyleStatus() + { + PlaylistItem? currentItem = room.Playlist.GetCurrentItem() ?? room.CurrentPlaylistItem; + Alpha = currentItem?.Freestyle == true ? 1 : 0; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomListing.cs similarity index 68% rename from osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs rename to osu.Game/Screens/OnlinePlay/Lounge/Components/RoomListing.cs index 6eda993f94..f04de97f9b 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomListing.cs @@ -7,14 +7,13 @@ using System.Collections.Specialized; using System.Diagnostics; using System.Globalization; using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Input.Bindings; using osu.Game.Online.Rooms; @@ -22,51 +21,69 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class RoomsContainer : CompositeDrawable, IKeyBindingHandler + public partial class RoomListing : CompositeDrawable, IKeyBindingHandler { - public readonly Bindable SelectedRoom = new Bindable(); + /// + /// Rooms which should be displayed. Should be managed externally. + /// + public readonly BindableList Rooms = new BindableList(); + + /// + /// The current filter criteria. Should be managed externally. + /// public readonly Bindable Filter = new Bindable(); - public IReadOnlyList Rooms => roomFlow.FlowingChildren.Cast().ToArray(); + /// + /// The currently user-selected room. + /// + public IBindable SelectedRoom => selectedRoom; - private readonly IBindableList rooms = new BindableList(); - private readonly FillFlowContainer roomFlow; + private readonly Bindable selectedRoom = new Bindable(); - [Resolved] - private IRoomManager roomManager { get; set; } = null!; + public IReadOnlyList DrawableRooms => roomFlow.FlowingChildren.Cast().ToArray(); + + private readonly ScrollContainer scroll; + private readonly FillFlowContainer roomFlow; // handle deselection public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public RoomsContainer() + public RoomListing() { - 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 + InternalChild = scroll = new Scroll { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = roomFlow = new FillFlowContainer + Masking = false, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 0.8f, + ScrollbarOverlapsContent = false, + Padding = new MarginPadding { Right = 5 }, + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(10), + Child = roomFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Margin = new MarginPadding { Vertical = 10 }, + } } }; } + private partial class Scroll : OsuScrollContainer + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + } + protected override void LoadComplete() { - rooms.CollectionChanged += roomsChanged; - roomManager.RoomsUpdated += updateSorting; - - rooms.BindTo(roomManager.Rooms); - + SelectedRoom.BindValueChanged(onSelectedRoomChanged, true); + Rooms.BindCollectionChanged(roomsChanged, true); Filter.BindValueChanged(criteria => applyFilterCriteria(criteria.NewValue), true); } @@ -109,7 +126,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components return true; } - static bool matchPermissions(DrawableLoungeRoom room, RoomPermissionsFilter accessType) + static bool matchPermissions(LoungeRoomPanel room, RoomPermissionsFilter accessType) { switch (accessType) { @@ -128,6 +145,14 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } + private void onSelectedRoomChanged(ValueChangedEvent room) + { + // scroll selected room into view on selection. + var drawable = DrawableRooms.FirstOrDefault(r => r.Room == room.NewValue); + if (drawable != null) + scroll.ScrollIntoView(drawable); + } + private void roomsChanged(object? sender, NotifyCollectionChangedEventArgs args) { switch (args.Action) @@ -155,7 +180,18 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void addRooms(IEnumerable rooms) { foreach (var room in rooms) - roomFlow.Add(new DrawableLoungeRoom(room) { SelectedRoom = SelectedRoom }); + { + var drawableRoom = new LoungeRoomPanel(room) + { + SelectedRoom = selectedRoom, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + roomFlow.Add(drawableRoom); + + roomFlow.SetLayoutPosition(drawableRoom, room.Pinned ? float.MinValue : -(room.RoomID ?? 0)); + } applyFilterCriteria(Filter.Value); } @@ -168,7 +204,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // selection may have a lease due to being in a sub screen. if (SelectedRoom.Value == r && !SelectedRoom.Disabled) - SelectedRoom.Value = null; + selectedRoom.Value = null; } } @@ -178,24 +214,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // 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.Category > RoomCategory.Normal - // Always show spotlight playlists at the top of the listing. - ? float.MinValue - : -(room.Room.RoomID ?? 0)); - } + selectedRoom.Value = null; } protected override bool OnClick(ClickEvent e) { if (!SelectedRoom.Disabled) - SelectedRoom.Value = null; + selectedRoom.Value = null; return base.OnClick(e); } @@ -226,7 +251,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components if (SelectedRoom.Disabled) return; - var visibleRooms = Rooms.AsEnumerable().Where(r => r.IsPresent); + var visibleRooms = DrawableRooms.AsEnumerable().Where(r => r.IsPresent); Room? room; @@ -242,17 +267,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // we already have a valid selection only change selection if we still have a room to switch to. if (room != null) - SelectedRoom.Value = room; + selectedRoom.Value = room; } #endregion - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (roomManager.IsNotNull()) - roomManager.RoomsUpdated -= updateSorting; - } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomModeFilter.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomModeFilter.cs new file mode 100644 index 0000000000..0c07233bff --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomModeFilter.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public enum RoomModeFilter + { + Open, + + [Description("Recently Ended")] + Ended, + Participated, + Owned, + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs similarity index 68% rename from osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs rename to osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs index c39ca347c7..fe03fca4b8 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPanel.cs @@ -12,48 +12,65 @@ 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.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; 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; using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public abstract partial class DrawableRoom : CompositeDrawable + public abstract partial class RoomPanel : CompositeDrawable, IHasContextMenu { protected const float CORNER_RADIUS = 10; - private const float height = 100; + private const float height = 80; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; public readonly Room Room; protected readonly Bindable SelectedItem = new Bindable(); protected Container ButtonsContainer { get; private set; } = null!; - private readonly Bindable roomType = new Bindable(); - private readonly Bindable roomCategory = new Bindable(); - private readonly Bindable hasPassword = new Bindable(); + protected bool ShowExternalLink { get; init; } = true; private DrawableRoomParticipantsList? drawableRoomParticipantsList; private RoomSpecialCategoryPill? specialCategoryPill; - private PasswordProtectedIcon? passwordIcon; + private CornerIcon? passwordIcon; + private CornerIcon? pinnedIcon; private EndDateInfo? endDateInfo; - private SpriteText? roomName; - private UpdateableBeatmapBackgroundSprite background = null!; + private RoomNameLine? roomName; private DelayedLoadWrapper wrapper = null!; + private CancellationTokenSource? beatmapLookupCancellation; - protected DrawableRoom(Room room) + /// + /// A fully-populated representation of the selected item's current beatmap. + /// + private readonly Bindable currentBeatmap = new Bindable(); + + protected RoomPanel(Room room) { Room = room; @@ -62,16 +79,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Masking = true; CornerRadius = CORNER_RADIUS; - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(40), - Radius = 5, - }; } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { ButtonsContainer = new Container { @@ -81,17 +92,25 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components AutoSizeAxes = Axes.X }; + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = colourProvider.Background6.Opacity(0.4f), + Radius = 4, + }; + InternalChildren = new Drawable[] { // This resolves internal 1px gaps due to applying the (parenting) corner radius and masking across multiple filling background sprites. new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.Background5, + Colour = colourProvider.Background5, }, - background = CreateBackground().With(d => + CreateBackground().With(d => { d.RelativeSizeAxes = Axes.Both; + d.Beatmap.BindTarget = currentBeatmap; }), wrapper = new DelayedLoadWrapper(() => new Container @@ -99,7 +118,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Name = @"Room content", RelativeSizeAxes = Axes.Both, // This negative padding resolves 1px gaps between this background and the background above. - Padding = new MarginPadding { Left = 20, Vertical = -0.5f }, + Padding = new MarginPadding { Left = 10, Vertical = -0.5f }, Child = new Container { RelativeSizeAxes = Axes.Both, @@ -110,7 +129,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.Background5, + Colour = colourProvider.Background5, Width = 0.2f, }, new Box @@ -118,7 +137,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Anchor = Anchor.TopRight, Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(colours.Background5, colours.Background5.Opacity(0.3f)), + Colour = ColourInfo.GradientHorizontal(colourProvider.Background5, colourProvider.Background5.Opacity(0.3f)), Width = 0.8f, }, new GridContainer @@ -139,8 +158,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { - Left = 20, - Right = DrawableRoomParticipantsList.SHEAR_WIDTH, + Left = 10, + Right = 10, Vertical = 5 }, Children = new Drawable[] @@ -169,6 +188,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, + new FreestyleStatusPill(Room) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, endDateInfo = new EndDateInfo(Room) { Anchor = Anchor.CentreLeft, @@ -184,14 +208,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Direction = FillDirection.Vertical, Children = new Drawable[] { - roomName = new TruncatingSpriteText - { - RelativeSizeAxes = Axes.X, - Font = OsuFont.GetFont(size: 28) - }, + roomName = new RoomNameLine(), new RoomStatusText(Room) { - SelectedItem = { BindTarget = SelectedItem } + Beatmap = { BindTarget = currentBeatmap } } } } @@ -235,7 +255,28 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } }, - passwordIcon = new PasswordProtectedIcon { Alpha = 0 } + passwordIcon = new CornerIcon + { + Alpha = 0, + Background = { Colour = colours.Gray8, }, + Icon = + { + Icon = FontAwesome.Solid.Lock, + Colour = colours.Gray3, + Rotation = 45, + }, + }, + pinnedIcon = new CornerIcon + { + Alpha = 0, + Background = { Colour = colours.Orange2 }, + Icon = + { + Icon = FontAwesome.Solid.Thumbtack, + Colour = colours.Gray3, + Rotation = 45, + }, + } }, }, }, 0) @@ -259,19 +300,25 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components wrapper.FadeInFromZero(200); + updateRoomID(); updateRoomName(); updateRoomCategory(); updateRoomType(); updateRoomHasPassword(); + updateRoomPinned(); }; - SelectedItem.BindValueChanged(item => background.Beatmap.Value = item.NewValue?.Beatmap, true); + SelectedItem.BindValueChanged(onSelectedItemChanged, true); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { switch (e.PropertyName) { + case nameof(Room.RoomID): + updateRoomID(); + break; + case nameof(Room.Name): updateRoomName(); break; @@ -287,9 +334,44 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components case nameof(Room.HasPassword): updateRoomHasPassword(); break; + + case nameof(Room.Pinned): + updateRoomPinned(); + break; } } + private void onSelectedItemChanged(ValueChangedEvent item) + { + if (item.NewValue?.Beatmap.OnlineID == item.OldValue?.Beatmap.OnlineID) + return; + + beatmapLookupCancellation?.Cancel(); + beatmapLookupCancellation?.Dispose(); + beatmapLookupCancellation = null; + + if (item.NewValue?.Beatmap == null) + { + currentBeatmap.Value = null; + return; + } + + var cancellationSource = beatmapLookupCancellation = new CancellationTokenSource(); + + beatmapLookupCache.GetBeatmapAsync(item.NewValue.Beatmap.OnlineID, cancellationSource.Token) + .ContinueWith(task => Schedule(() => + { + if (!cancellationSource.IsCancellationRequested) + currentBeatmap.Value = task.GetResultSafely(); + }), cancellationSource.Token); + } + + private void updateRoomID() + { + if (roomName != null && ShowExternalLink) + roomName.Link = Room.GetOnlineURL(api); + } + private void updateRoomName() { if (roomName != null) @@ -316,6 +398,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components passwordIcon.Alpha = Room.HasPassword ? 1 : 0; } + private void updateRoomPinned() + { + if (pinnedIcon != null) + pinnedIcon.Alpha = Room.Pinned ? 1 : 0; + } + private int numberOfAvatars = 7; public int NumberOfAvatars @@ -330,6 +418,26 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } + public virtual MenuItem[] ContextMenuItems + { + get + { + var items = new List(); + + string? url = Room.GetOnlineURL(api); + + if (url != null) + { + items.AddRange([ + new OsuMenuItem("View in browser", MenuItemType.Standard, () => game?.OpenUrlExternally(url)), + new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyToClipboard(url)) + ]); + } + + return items.ToArray(); + } + } + protected virtual UpdateableBeatmapBackgroundSprite CreateBackground() => new UpdateableBeatmapBackgroundSprite(); protected virtual IEnumerable CreateBottomDetails() @@ -371,14 +479,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private partial class RoomStatusText : CompositeDrawable { - public readonly IBindable SelectedItem = new Bindable(); + public readonly Bindable Beatmap = new Bindable(); [Resolved] private OsuColour colours { get; set; } = null!; - [Resolved] - private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; - private readonly Room room; private SpriteText statusText = null!; private LinkFlowContainer beatmapText = null!; @@ -411,12 +516,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { statusText = new OsuSpriteText { - Font = OsuFont.Default.With(size: 16), + Font = OsuFont.Style.Caption2, Colour = colours.Lime1 }, beatmapText = new LinkFlowContainer(s => { - s.Font = OsuFont.Default.With(size: 16); + s.Font = OsuFont.Style.Caption2; s.Colour = colours.Lime1; }) { @@ -434,14 +539,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components protected override void LoadComplete() { base.LoadComplete(); - SelectedItem.BindValueChanged(onSelectedItemChanged, true); + + Beatmap.BindValueChanged(onBeatmapChanged, true); } - private CancellationTokenSource? beatmapLookupCancellation; - - private void onSelectedItemChanged(ValueChangedEvent item) + private void onBeatmapChanged(ValueChangedEvent beatmap) { - beatmapLookupCancellation?.Cancel(); beatmapText.Clear(); if (room.Type == MatchType.Playlists) @@ -450,38 +553,26 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components return; } - var beatmap = item.NewValue?.Beatmap; - if (beatmap == null) - return; + statusText.Text = "Currently playing "; - var cancellationSource = beatmapLookupCancellation = new CancellationTokenSource(); - beatmapLookupCache.GetBeatmapAsync(beatmap.OnlineID, cancellationSource.Token) - .ContinueWith(task => Schedule(() => - { - if (cancellationSource.IsCancellationRequested) - return; - - var retrievedBeatmap = task.GetResultSafely(); - - statusText.Text = "Currently playing "; - - if (retrievedBeatmap != null) - { - beatmapText.AddLink(retrievedBeatmap.GetDisplayTitleRomanisable(), - LinkAction.OpenBeatmap, - retrievedBeatmap.OnlineID.ToString(), - creationParameters: s => s.Truncate = true); - } - else - beatmapText.AddText("unknown beatmap"); - }), cancellationSource.Token); + if (beatmap.NewValue != null) + { + beatmapText.AddLink(beatmap.NewValue.GetDisplayTitleRomanisable(), + LinkAction.OpenBeatmap, + beatmap.NewValue.OnlineID.ToString(), + creationParameters: s => s.Truncate = true); + } + else + beatmapText.AddText("unknown beatmap"); } } - public partial class PasswordProtectedIcon : CompositeDrawable + public partial class CornerIcon : CompositeDrawable { - [BackgroundDependencyLoader] - private void load(OsuColour colours) + public SpriteIcon Icon { get; } + public Box Background { get; } + + public CornerIcon() { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; @@ -490,25 +581,89 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components InternalChildren = new Drawable[] { - new Box + Background = new Box { Anchor = Anchor.TopRight, Origin = Anchor.TopCentre, - Colour = colours.Gray5, Rotation = 45, RelativeSizeAxes = Axes.Both, Width = 2, }, - new SpriteIcon + Icon = new SpriteIcon { - Icon = FontAwesome.Solid.Lock, Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, + Origin = Anchor.Centre, + Position = new Vector2(-13, 13), Margin = new MarginPadding(6), Size = new Vector2(14), } }; } } + + public partial class RoomNameLine : FillFlowContainer + { + private readonly TruncatingSpriteText spriteText; + private readonly ExternalLinkButton linkButton; + + public LocalisableString Text + { + get => spriteText.Text; + set => spriteText.Text = value; + } + + private string? link; + + public string? Link + { + get => link; + set + { + link = value; + updateLink(); + } + } + + public RoomNameLine() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Horizontal; + + Children = new Drawable[] + { + spriteText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Heading2, + }, + linkButton = new ExternalLinkButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Horizontal = 6, Bottom = 4 }, + Alpha = 0f, + }, + }; + } + + private void updateLink() + { + if (link == null) + linkButton.Hide(); + else + { + linkButton.Show(); + linkButton.Link = link; + } + } + + protected override void Update() + { + base.Update(); + spriteText.MaxWidth = DrawWidth - linkButton.LayoutSize.X; + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs index 53fbf670e1..a4d5043ff5 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs @@ -1,17 +1,11 @@ -// 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.ComponentModel; - namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public enum RoomStatusFilter { - Open, - - [Description("Recently Ended")] - Ended, - Participated, - Owned, + Idle, + Playing, } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs index b3dc617fd6..092f17a643 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Online.Rooms; +using osu.Game.Localisation; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { @@ -35,8 +36,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Pill.Background.Alpha = 1; room.PropertyChanged += onRoomPropertyChanged; - updateDisplay(); + // Timed update required to track rooms which have hit the end time, see `HasEnded`. + Scheduler.AddDelayed(updateDisplay, 1000, true); + updateDisplay(); FinishTransforms(true); } @@ -46,6 +49,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { case nameof(Room.Status): case nameof(Room.EndDate): + case nameof(Room.HasPassword): updateDisplay(); break; } @@ -53,8 +57,23 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void updateDisplay() { - Pill.Background.FadeColour(room.Status.GetAppropriateColour(colours), 100); - TextFlow.Text = room.Status.Message; + Pill.Background.FadeColour(colours.ForRoomStatus(room), 100); + + if (room.HasEnded) + TextFlow.Text = RoomStatusPillStrings.Ended; + else + { + switch (room.Status) + { + case RoomStatus.Playing: + TextFlow.Text = RoomStatusPillStrings.Playing; + break; + + default: + TextFlow.Text = room.HasPassword ? RoomStatusPillStrings.OpenPrivate : RoomStatusPillStrings.Open; + break; + } + } } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/IOnlinePlayLounge.cs b/osu.Game/Screens/OnlinePlay/Lounge/IOnlinePlayLounge.cs new file mode 100644 index 0000000000..c9f6921328 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/IOnlinePlayLounge.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.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Lounge +{ + public interface IOnlinePlayLounge + { + /// + /// Attempts to join the given room. + /// + /// The room to join. + /// The password. + /// A delegate to invoke if the user joined the room. + /// A delegate to invoke if the user is not able to join the room. + void Join(Room room, string? password, Action? onSuccess = null, Action? onFailure = null); + + /// + /// Copies the given room and opens it as a fresh (not-yet-created) one. + /// + /// The room to copy. + void OpenCopy(Room room); + + /// + /// Closes the given room. + /// + /// The room to close. + void Close(Room room); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeListingPoller.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeListingPoller.cs new file mode 100644 index 0000000000..d92ae7eb6e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeListingPoller.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge.Components; + +namespace osu.Game.Screens.OnlinePlay.Lounge +{ + /// + /// Polls for rooms for the main lounge listing. + /// + public partial class LoungeListingPoller : PollingComponent + { + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public required Action RoomsReceived { get; init; } + public readonly IBindable Filter = new Bindable(); + + private GetRoomsRequest? lastPollRequest; + + protected override Task Poll() + { + if (!api.IsLoggedIn) + return base.Poll(); + + if (Filter.Value == null) + return base.Poll(); + + lastPollRequest?.Cancel(); + + var tcs = new TaskCompletionSource(); + var req = new GetRoomsRequest(Filter.Value); + + req.Success += result => + { + RoomsReceived(result.Where(r => r.Category != RoomCategory.DailyChallenge).ToArray()); + tcs.SetResult(true); + }; + + req.Failure += _ => tcs.SetResult(false); + + api.Queue(req); + + lastPollRequest = req; + + return tcs.Task; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeRoomPanel.cs similarity index 88% rename from osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs rename to osu.Game/Screens/OnlinePlay/Lounge/LoungeRoomPanel.cs index 7d36cec7ba..12b38a9677 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeRoomPanel.cs @@ -17,6 +17,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -24,9 +25,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Input.Bindings; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; @@ -38,9 +37,9 @@ using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Lounge { /// - /// A with lounge-specific interactions such as selection and hover sounds. + /// A with lounge-specific interactions such as selection and hover sounds. /// - public partial class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler + public partial class LoungeRoomPanel : RoomPanel, IFilterable, IHasPopover, IKeyBindingHandler { private const float transition_duration = 60; private const float selection_border_width = 4; @@ -52,7 +51,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge } [Resolved(canBeNull: true)] - private LoungeSubScreen? lounge { get; set; } + private IOnlinePlayLounge? lounge { get; set; } [Resolved] private IDialogOverlay? dialogOverlay { get; set; } @@ -65,9 +64,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private Sample? sampleJoin; private Drawable selectionBox = null!; - public DrawableLoungeRoom(Room room) + public LoungeRoomPanel(Room room) : base(room) { + ShowExternalLink = false; } [BackgroundDependencyLoader] @@ -156,28 +156,22 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public Popover GetPopover() => new PasswordEntryPopover(Room); - public MenuItem[] ContextMenuItems + public override MenuItem[] ContextMenuItems { get { - var items = new List - { - new OsuMenuItem("Create copy", MenuItemType.Standard, () => - { - lounge?.OpenCopy(Room); - }) - }; + var items = new List(); - if (Room.Type == MatchType.Playlists && Room.Host?.Id == api.LocalUser.Value.Id && Room.StartDate?.AddMinutes(5) >= DateTimeOffset.Now && Room.Status is not RoomStatusEnded) + items.AddRange(base.ContextMenuItems); + + items.Add(new OsuMenuItemSpacer()); + items.Add(new OsuMenuItem("Create copy", MenuItemType.Standard, () => lounge?.OpenCopy(Room))); + + if (Room.Type == MatchType.Playlists && Room.Host?.Id == api.LocalUser.Value.Id && Room.StartDate?.AddMinutes(5) >= DateTimeOffset.Now && !Room.HasEnded) { items.Add(new OsuMenuItem("Close playlist", MenuItemType.Destructive, () => { - dialogOverlay?.Push(new ClosePlaylistDialog(Room, () => - { - var request = new ClosePlaylistRequest(Room.RoomID!.Value); - request.Success += () => lounge?.RefreshRooms(); - api.Queue(request); - })); + dialogOverlay?.Push(new ClosePlaylistDialog(Room, () => lounge?.Close(Room))); })); } @@ -240,7 +234,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private readonly Room room; [Resolved(canBeNull: true)] - private LoungeSubScreen? lounge { get; set; } + private IOnlinePlayLounge? lounge { get; set; } public override bool HandleNonPositionalInput => true; @@ -314,13 +308,14 @@ namespace osu.Game.Screens.OnlinePlay.Lounge GetContainingFocusManager()?.TriggerFocusContention(passwordTextBox); } - private void joinFailed(string error) => Schedule(() => + private void joinFailed(string message, Exception? exception) => Schedule(() => { passwordTextBox.Text = string.Empty; GetContainingFocusManager()!.ChangeFocus(passwordTextBox); - errorText.Text = error; + Logger.Log($"Failed to join room with password. {exception}"); + errorText.Text = message; errorText .FadeIn() .FlashColour(Color4.White, 200) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index 5d0983f09c..b4b039501f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -8,38 +8,40 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; 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.Configuration; -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.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; +using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge { [Cached] - public abstract partial class LoungeSubScreen : OnlinePlaySubScreen + [Cached(typeof(IOnlinePlayLounge))] + public abstract partial class LoungeSubScreen : OnlinePlaySubScreen, IOnlinePlayLounge { public override string Title => "Lounge"; protected override BackgroundScreen CreateBackground() => new LoungeBackgroundScreen { - SelectedRoom = { BindTarget = SelectedRoom } + SelectedRoom = { BindTarget = roomListing.SelectedRoom } }; protected override UserActivity InitialActivity => new UserActivity.SearchingForLobby(); @@ -51,10 +53,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge AutoSizeAxes = Axes.Both }; - protected ListingPollingComponent ListingPollingComponent { get; private set; } = null!; - - protected readonly Bindable SelectedRoom = new Bindable(); - [Resolved] private MusicController music { get; set; } = null!; @@ -73,31 +71,39 @@ namespace osu.Game.Screens.OnlinePlay.Lounge [Resolved] protected OsuConfigManager Config { get; private set; } = null!; - private IDisposable? joiningRoomOperation { get; set; } - private LeasedBindable? selectionLease; + private IDisposable? joiningRoomOperation; private readonly Bindable filter = new Bindable(); + private readonly Bindable hasListingResults = new Bindable(); private readonly IBindable operationInProgress = new Bindable(); private readonly IBindable isIdle = new BindableBool(); + private RoomListing roomListing = null!; + private LoungeListingPoller listingPoller = null!; private PopoverContainer popoverContainer = null!; private LoadingLayer loadingLayer = null!; - private RoomsContainer roomsContainer = null!; private SearchTextBox searchTextBox = null!; - private Dropdown statusDropdown = null!; + + protected Dropdown StatusDropdown { get; private set; } = null!; [BackgroundDependencyLoader(true)] private void load() { + Masking = true; + const float controls_area_height = 25f; if (idleTracker != null) isIdle.BindTo(idleTracker.IsIdle); - OsuScrollContainer scrollContainer; + Color4 bg = Color4Extensions.FromHex("#070405"); InternalChildren = new Drawable[] { - ListingPollingComponent = CreatePollingComponent().With(c => c.Filter.BindTarget = filter), + listingPoller = new LoungeListingPoller + { + RoomsReceived = onListingReceived, + Filter = { BindTarget = filter } + }, popoverContainer = new PopoverContainer { Name = @"Rooms area", @@ -107,78 +113,89 @@ namespace osu.Game.Screens.OnlinePlay.Lounge Horizontal = WaveOverlayContainer.WIDTH_PADDING, Top = Header.HEIGHT + controls_area_height + 20, }, - Child = scrollContainer = new OsuScrollContainer + Child = roomListing = new RoomListing { RelativeSizeAxes = Axes.Both, - ScrollbarOverlapsContent = false, - Child = roomsContainer = new RoomsContainer - { - Filter = { BindTarget = filter }, - SelectedRoom = { BindTarget = SelectedRoom } - } - }, + Filter = { BindTarget = filter }, + } }, loadingLayer = new LoadingLayer(true), - new FillFlowContainer + new Container { - Name = @"Header area flow", + Name = "Header area", RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = WaveOverlayContainer.WIDTH_PADDING }, - Direction = FillDirection.Vertical, Children = new Drawable[] { - new Container + new Box { - RelativeSizeAxes = Axes.X, - Height = Header.HEIGHT, - Child = searchTextBox = new BasicSearchTextBox - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.X, - Width = 0.6f, - }, + Colour = ColourInfo.GradientVertical(bg, bg.Opacity(0.75f)), + RelativeSizeAxes = Axes.Both, + Height = 0.8f, }, - new Container + new Box { + Colour = ColourInfo.GradientVertical(bg.Opacity(0.75f), bg.Opacity(0)), + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Y = 0.8f, + // Intentionally taller than the header for a more gradual fade + Height = 0.5f, + }, + new FillFlowContainer + { + Name = @"Header area flow", RelativeSizeAxes = Axes.X, - Height = controls_area_height, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.WIDTH_PADDING }, + Direction = FillDirection.Vertical, Children = new Drawable[] { - Buttons.WithChild(CreateNewRoomButton().With(d => + new Container { - 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 => + RelativeSizeAxes = Axes.X, + Height = Header.HEIGHT, + Child = searchTextBox = new BasicSearchTextBox { - d.Anchor = Anchor.TopRight; - d.Origin = Anchor.TopRight; - })) + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + Width = 0.6f, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = controls_area_height, + Children = new Drawable[] + { + Buttons.WithChild(CreateNewRoomButton().With(d => + { + d.Anchor = Anchor.BottomLeft; + d.Origin = Anchor.BottomLeft; + d.Size = new Vector2(150, 30f); + 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; + })) + } + } } - } - } - }, + }, + }, + } }, }; - - // scroll selected room into view on selection. - SelectedRoom.BindValueChanged(val => - { - var drawable = roomsContainer.Rooms.FirstOrDefault(r => r.Room == val.NewValue); - if (drawable != null) - scrollContainer.ScrollIntoView(drawable); - }); } protected override void LoadComplete() @@ -187,7 +204,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge searchTextBox.Current.BindValueChanged(_ => updateFilterDebounced()); ruleset.BindValueChanged(_ => UpdateFilter()); - isIdle.BindValueChanged(_ => updatePollingRate(this.IsCurrentScreen()), true); if (ongoingOperationTracker != null) @@ -196,11 +212,38 @@ namespace osu.Game.Screens.OnlinePlay.Lounge operationInProgress.BindValueChanged(_ => updateLoadingLayer()); } - ListingPollingComponent.InitialRoomsReceived.BindValueChanged(_ => updateLoadingLayer(), true); + hasListingResults.BindValueChanged(_ => updateLoadingLayer()); + filter.BindValueChanged(_ => + { + roomListing.Rooms.Clear(); + RefreshRooms(); + }); + + updateLoadingLayer(); updateFilter(); } + private void onListingReceived(Room[] result) + { + Dictionary localRoomsById = roomListing.Rooms.ToDictionary(r => r.RoomID!.Value); + Dictionary resultRoomsById = result.ToDictionary(r => r.RoomID!.Value); + + // Remove all local rooms no longer in the result set. + roomListing.Rooms.RemoveAll(r => !resultRoomsById.ContainsKey(r.RoomID!.Value)); + + // Add or update local rooms with the result set. + foreach (var r in result) + { + if (localRoomsById.TryGetValue(r.RoomID!.Value, out Room? existingRoom)) + existingRoom.CopyFrom(r); + else + roomListing.Rooms.Add(r); + } + + hasListingResults.Value = true; + } + #region Filtering public void UpdateFilter() => Scheduler.AddOnce(updateFilter); @@ -223,20 +266,20 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { SearchString = searchTextBox.Current.Value, Ruleset = ruleset.Value, - Status = statusDropdown.Current.Value + Mode = StatusDropdown.Current.Value }; protected virtual IEnumerable CreateFilterControls() { - statusDropdown = new SlimEnumDropdown + StatusDropdown = new SlimEnumDropdown { RelativeSizeAxes = Axes.None, Width = 160, }; - statusDropdown.Current.BindValueChanged(_ => UpdateFilter()); + StatusDropdown.Current.BindValueChanged(_ => UpdateFilter()); - yield return statusDropdown; + yield return StatusDropdown; } #endregion @@ -251,17 +294,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { base.OnResuming(e); - Debug.Assert(selectionLease != null); - - selectionLease.Return(); - selectionLease = null; - - if (SelectedRoom.Value?.RoomID == null) - SelectedRoom.Value = new Room(); - music.EnsurePlayingSomething(); onReturning(); + + // Poll for any newly-created rooms (including potentially the user's own). + listingPoller.PollImmediately(); } public override bool OnExiting(ScreenExitEvent e) @@ -296,31 +334,33 @@ namespace osu.Game.Screens.OnlinePlay.Lounge popoverContainer.HidePopover(); } - public virtual void Join(Room room, string? password, Action? onSuccess = null, Action? onFailure = null) => Schedule(() => + public void Join(Room room, string? password, Action? onSuccess = null, Action? onFailure = null) => Schedule(() => { if (joiningRoomOperation != null) return; joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); - RoomManager?.JoinRoom(room, password, _ => + JoinInternal(room, password, r => { Open(room); joiningRoomOperation?.Dispose(); joiningRoomOperation = null; onSuccess?.Invoke(room); - }, error => + }, (message, exception) => { joiningRoomOperation?.Dispose(); joiningRoomOperation = null; - onFailure?.Invoke(error); + + if (onFailure != null) + onFailure(message, exception); + else + Logger.Error(exception, message); }); }); - /// - /// Copies a room and opens it as a fresh (not-yet-created) one. - /// - /// The room to copy. + protected abstract void JoinInternal(Room room, string? password, Action onSuccess, Action onFailure); + public void OpenCopy(Room room) { Debug.Assert(room.RoomID != null); @@ -357,6 +397,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge api.Queue(req); } + public abstract void Close(Room room); + /// /// Push a room as a new subscreen. /// @@ -370,20 +412,17 @@ namespace osu.Game.Screens.OnlinePlay.Lounge OpenNewRoom(room ?? CreateNewRoom()); }); - protected virtual void OpenNewRoom(Room room) + protected virtual void OpenNewRoom(Room room) => this.Push(CreateRoomSubScreen(room)); + + public void RefreshRooms() { - selectionLease = SelectedRoom.BeginLease(false); - Debug.Assert(selectionLease != null); - selectionLease.Value = room; - - this.Push(CreateRoomSubScreen(room)); + hasListingResults.Value = false; + listingPoller.PollImmediately(); } - public void RefreshRooms() => ListingPollingComponent.PollImmediately(); - private void updateLoadingLayer() { - if (operationInProgress.Value || !ListingPollingComponent.InitialRoomsReceived.Value) + if (operationInProgress.Value || !hasListingResults.Value) loadingLayer.Show(); else loadingLayer.Hide(); @@ -392,11 +431,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private void updatePollingRate(bool isCurrentScreen) { if (!isCurrentScreen) - ListingPollingComponent.TimeBetweenPolls.Value = 0; + listingPoller.TimeBetweenPolls.Value = 0; else - ListingPollingComponent.TimeBetweenPolls.Value = isIdle.Value ? 120000 : 15000; + listingPoller.TimeBetweenPolls.Value = isIdle.Value ? 120000 : 15000; - Logger.Log($"Polling adjusted (listing: {ListingPollingComponent.TimeBetweenPolls.Value})"); + Logger.Log($"Polling adjusted (listing: {listingPoller.TimeBetweenPolls.Value})"); } protected abstract OsuButton CreateNewRoomButton(); @@ -407,8 +446,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge /// The created . protected abstract Room CreateNewRoom(); - protected abstract RoomSubScreen CreateRoomSubScreen(Room room); - - protected abstract ListingPollingComponent CreatePollingComponent(); + protected abstract OnlinePlaySubScreen CreateRoomSubScreen(Room room); } } diff --git a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs deleted file mode 100644 index 0c993f4abf..0000000000 --- a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs +++ /dev/null @@ -1,90 +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.ComponentModel; -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.Resources.Localisation.Web; -using osu.Game.Screens.OnlinePlay.Lounge.Components; -using osu.Game.Screens.OnlinePlay.Match.Components; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Match -{ - public partial class DrawableMatchRoom : DrawableRoom - { - public Action? OnEdit; - - public new required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); - private readonly bool allowEdit; - private Drawable? editButton; - - public DrawableMatchRoom(Room room, bool allowEdit = true) - : base(room) - { - this.allowEdit = allowEdit; - - base.SelectedItem.BindTo(SelectedItem); - } - - [BackgroundDependencyLoader] - private void load() - { - if (allowEdit) - { - ButtonsContainer.Add(editButton = new PurpleRoundedButton - { - RelativeSizeAxes = Axes.Y, - Size = new Vector2(100, 1), - Text = CommonStrings.ButtonsEdit, - Action = () => OnEdit?.Invoke() - }); - } - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Room.PropertyChanged += onRoomPropertyChanged; - updateRoomHost(); - } - - private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(Room.Host)) - updateRoomHost(); - } - - private void updateRoomHost() - { - if (editButton != null) - editButton.Alpha = Room.Host?.Equals(api.LocalUser.Value) == true ? 1 : 0; - } - - protected override UpdateableBeatmapBackgroundSprite CreateBackground() => base.CreateBackground().With(d => - { - d.BackgroundLoadDelay = 0; - }); - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - Room.PropertyChanged -= onRoomPropertyChanged; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs deleted file mode 100644 index 4ef31c02c3..0000000000 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ /dev/null @@ -1,540 +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.ComponentModel; -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.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Screens; -using osu.Game.Audio; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Menu; -using osu.Game.Screens.OnlinePlay.Match.Components; -using osu.Game.Screens.OnlinePlay.Multiplayer; -using Container = osu.Framework.Graphics.Containers.Container; - -namespace osu.Game.Screens.OnlinePlay.Match -{ - [Cached(typeof(IPreviewTrackOwner))] - public abstract partial class RoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner - { - public readonly Bindable SelectedItem = new Bindable(); - - public override bool? ApplyModTrackAdjustments => true; - - 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. - /// This will be shown/hidden automatically when applicable. - /// - protected Drawable? UserModsSection; - - private Sample? sampleStart; - - /// - /// Any mods applied by/to the local user. - /// - protected readonly Bindable> UserMods = new Bindable>(Array.Empty()); - - [Resolved(CanBeNull = true)] - private IOverlayManager? overlayManager { get; set; } - - [Resolved] - private MusicController music { get; set; } = null!; - - [Resolved] - private BeatmapManager beatmapManager { get; set; } = null!; - - [Resolved] - protected RulesetStore Rulesets { get; private set; } = null!; - - [Resolved] - protected IAPIProvider API { get; private set; } = null!; - - [Resolved(canBeNull: true)] - protected OnlinePlayScreen? ParentScreen { get; private set; } - - [Resolved] - private PreviewTrackManager previewTrackManager { get; set; } = null!; - - [Resolved(canBeNull: true)] - protected IDialogOverlay? DialogOverlay { get; private set; } - - [Cached] - private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); - - protected IBindable BeatmapAvailability => beatmapAvailabilityTracker.Availability; - - public readonly Room Room; - private readonly bool allowEdit; - - internal ModSelectOverlay UserModsSelectOverlay { get; private set; } = null!; - - private IDisposable? userModsSelectOverlayRegistration; - private RoomSettingsOverlay settingsOverlay = null!; - private Drawable mainContent = null!; - - /// - /// 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 }; - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); - - InternalChild = new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - beatmapAvailabilityTracker, - new MultiplayerRoomSounds(), - 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 = 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, - }, - } - } - } - } - }, - 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() - }, - } - } - } - } - } - } - }; - - LoadComponent(UserModsSelectOverlay = new RoomModSelectOverlay - { - SelectedItem = { BindTarget = SelectedItem }, - SelectedMods = { BindTarget = UserMods }, - IsValidMod = _ => false - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); - UserMods.BindValueChanged(_ => Scheduler.AddOnce(UpdateMods)); - - beatmapAvailabilityTracker.SelectedItem.BindTo(SelectedItem); - beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateWorkingBeatmap()); - - userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(UserModsSelectOverlay); - - Room.PropertyChanged += onRoomPropertyChanged; - updateSetupState(); - } - - private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(Room.RoomID)) - updateSetupState(); - } - - private void updateSetupState() - { - if (Room.RoomID == 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(); - } - } - - protected virtual bool IsConnected => API.State.Value == APIState.Online; - - public override bool OnBackButton() - { - if (Room.RoomID == null) - { - if (!ensureExitConfirmed()) - return true; - - 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(); - } - - protected void ShowUserModSelect() => UserModsSelectOverlay.Show(); - - public override void OnEntering(ScreenTransitionEvent e) - { - base.OnEntering(e); - beginHandlingTrack(); - } - - public override void OnSuspending(ScreenTransitionEvent e) - { - // Should be a noop in most cases, but let's ensure beyond doubt that the beatmap is in a correct state. - updateWorkingBeatmap(); - - onLeaving(); - base.OnSuspending(e); - } - - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - updateWorkingBeatmap(); - beginHandlingTrack(); - Scheduler.AddOnce(UpdateMods); - Scheduler.AddOnce(updateRuleset); - } - - protected bool ExitConfirmed { get; private set; } - - public override bool OnExiting(ScreenExitEvent e) - { - if (!ensureExitConfirmed()) - return true; - - RoomManager?.PartRoom(); - Mods.Value = Array.Empty(); - - onLeaving(); - - return base.OnExiting(e); - } - - private bool ensureExitConfirmed() - { - if (ExitConfirmed) - return true; - - if (!IsConnected) - return true; - - bool hasUnsavedChanges = Room.RoomID == null && Room.Playlist.Count > 0; - - if (DialogOverlay == null || !hasUnsavedChanges) - return true; - - // if the dialog is already displayed, block exiting until the user explicitly makes a decision. - if (DialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) - { - discardChangesDialog.Flash(); - return false; - } - - DialogOverlay.Push(new ConfirmDiscardChangesDialog(() => - { - ExitConfirmed = true; - settingsOverlay.Hide(); - this.Exit(); - })); - - return false; - } - - protected void StartPlay() - { - if (SelectedItem.Value == null) - return; - - // User may be at song select or otherwise when the host starts gameplay. - // Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state. - if (!this.IsCurrentScreen()) - { - this.MakeCurrent(); - - Schedule(StartPlay); - return; - } - - sampleStart?.Play(); - - // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). - var targetScreen = (Screen?)ParentScreen ?? this; - - targetScreen.Push(CreateGameplayScreen(SelectedItem.Value)); - } - - /// - /// Creates the gameplay screen to be entered. - /// - /// The playlist item about to be played. - /// The screen to enter. - protected abstract Screen CreateGameplayScreen(PlaylistItem selectedItem); - - private void selectedItemChanged() - { - updateWorkingBeatmap(); - - if (SelectedItem.Value is not PlaylistItem selected) - return; - - var rulesetInstance = Rulesets.GetRuleset(selected.RulesetID)?.CreateInstance(); - Debug.Assert(rulesetInstance != null); - var allowedMods = selected.AllowedMods.Select(m => m.ToMod(rulesetInstance)); - - // Remove any user mods that are no longer allowed. - UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToList(); - - UpdateMods(); - updateRuleset(); - - if (!selected.AllowedMods.Any()) - { - UserModsSection?.Hide(); - UserModsSelectOverlay.Hide(); - UserModsSelectOverlay.IsValidMod = _ => false; - } - else - { - UserModsSection?.Show(); - UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); - } - } - - private void updateWorkingBeatmap() - { - if (SelectedItem.Value == null || !this.IsCurrentScreen()) - return; - - var beatmap = SelectedItem.Value?.Beatmap; - - // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - var localBeatmap = beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID); - - UserModsSelectOverlay.Beatmap.Value = Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); - } - - protected virtual void UpdateMods() - { - if (SelectedItem.Value == null || !this.IsCurrentScreen()) - return; - - var rulesetInstance = Rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); - Debug.Assert(rulesetInstance != null); - Mods.Value = UserMods.Value.Concat(SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToList(); - } - - private void updateRuleset() - { - if (SelectedItem.Value == null || !this.IsCurrentScreen()) - return; - - Ruleset.Value = Rulesets.GetRuleset(SelectedItem.Value.RulesetID); - } - - private void beginHandlingTrack() - { - Beatmap.BindValueChanged(applyLoopingToTrack, true); - } - - private void onLeaving() - { - UserModsSelectOverlay.Hide(); - endHandlingTrack(); - - previewTrackManager.StopAnyPlaying(this); - } - - private void endHandlingTrack() - { - Beatmap.ValueChanged -= applyLoopingToTrack; - cancelTrackLooping(); - } - - private void applyLoopingToTrack(ValueChangedEvent? _ = null) - { - if (!this.IsCurrentScreen()) - return; - - var track = Beatmap.Value?.Track; - - if (track != null) - { - Beatmap.Value!.PrepareTrackForPreview(true); - music.EnsurePlayingSomething(); - } - } - - private void cancelTrackLooping() - { - var track = Beatmap.Value?.Track; - - if (track != null) - track.Looping = false; - } - - /// - /// 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); - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - userModsSelectOverlayRegistration?.Dispose(); - Room.PropertyChanged -= onRoomPropertyChanged; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs new file mode 100644 index 0000000000..093d9f6117 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Match; +using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro +{ + /// + /// A brief intro animation that introduces matchmaking to the user. + /// + public partial class ScreenIntro : OsuScreen + { + public override bool DisallowExternalBeatmapRulesetChanges => false; + + public override bool? ApplyModTrackAdjustments => true; + + public override bool ShowFooter => true; + + private Container introContent = null!; + + private Container titleContainer = null!; + + private bool animationBegan; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [Resolved] + private MusicController musicController { get; set; } = null!; + + private Sample? dateWindupSample; + private Sample? dateImpactSample; + private Sample? beatmapWindupSample; + private Sample? beatmapImpactSample; + + private SampleChannel? dateWindupChannel; + private SampleChannel? dateImpactChannel; + private SampleChannel? beatmapWindupChannel; + private SampleChannel? beatmapImpactChannel; + + private IDisposable? duckOperation; + + protected override BackgroundScreen CreateBackground() => new MatchmakingIntroBackgroundScreen(colourProvider); + + public ScreenIntro() + { + ValidForResume = false; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + InternalChildren = new Drawable[] + { + introContent = new Container + { + Alpha = 0f, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + titleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + CornerRadius = 10f, + Masking = true, + X = 10, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Quick Play", + Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + Shear = -OsuGame.SHEAR, + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + } + }, + } + }, + } + } + }; + + dateWindupSample = audio.Samples.Get(@"DailyChallenge/date-windup"); + dateImpactSample = audio.Samples.Get(@"DailyChallenge/date-impact"); + beatmapWindupSample = audio.Samples.Get(@"DailyChallenge/beatmap-windup"); + beatmapImpactSample = audio.Samples.Get(@"DailyChallenge/beatmap-impact"); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + this.FadeInFromZero(400, Easing.OutQuint); + + updateAnimationState(); + playDateWindupSample(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + duckOperation?.Dispose(); + + this.FadeOut(800, Easing.OutQuint); + base.OnSuspending(e); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + duckOperation?.Dispose(); + return false; + } + + private void updateAnimationState() + { + if (animationBegan) + return; + + beginAnimation(); + animationBegan = true; + } + + private void beginAnimation() + { + using (BeginDelayedSequence(200)) + { + introContent.Show(); + + titleContainer + .ScaleTo(2) + .Then() + .ScaleTo(1, 400, Easing.In); + + using (BeginDelayedSequence(150)) + { + Schedule(() => + { + playDateImpactSample(); + playBeatmapWindupSample(); + + duckOperation?.Dispose(); + duckOperation = musicController.Duck(new DuckParameters + { + RestoreDuration = 1500f, + }); + }); + } + + using (BeginDelayedSequence(1000)) + { + using (BeginDelayedSequence(100)) + { + titleContainer + .ScaleTo(0.4f, 400, Easing.In) + .FadeOut(500, Easing.OutQuint); + } + + using (BeginDelayedSequence(240)) + { + Schedule(() => + { + if (this.IsCurrentScreen()) + this.Push(new ScreenQueue()); + }); + } + } + } + } + + private void playDateWindupSample() + { + dateWindupChannel = dateWindupSample?.GetChannel(); + dateWindupChannel?.Play(); + } + + private void playDateImpactSample() + { + dateImpactChannel = dateImpactSample?.GetChannel(); + dateImpactChannel?.Play(); + } + + private void playBeatmapWindupSample() + { + beatmapWindupChannel = beatmapWindupSample?.GetChannel(); + beatmapWindupChannel?.Play(); + } + + private void playBeatmapImpactSample() + { + beatmapImpactChannel = beatmapImpactSample?.GetChannel(); + beatmapImpactChannel?.Play(); + } + + protected override void Dispose(bool isDisposing) + { + resetAudio(); + base.Dispose(isDisposing); + } + + private void resetAudio() + { + dateWindupChannel?.Stop(); + dateImpactChannel?.Stop(); + beatmapWindupChannel?.Stop(); + beatmapImpactChannel?.Stop(); + duckOperation?.Dispose(); + } + + private partial class MatchmakingIntroBackgroundScreen : RoomBackgroundScreen + { + private readonly OverlayColourProvider colourProvider; + + public MatchmakingIntroBackgroundScreen(OverlayColourProvider colourProvider) + : base(null) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(new Box + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.6f), + }); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs new file mode 100644 index 0000000000..d27b0e3818 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs @@ -0,0 +1,400 @@ +// Copyright (c) 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.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Toolkit.HighPerformance; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class BeatmapSelectGrid : CompositeDrawable + { + public const double ARRANGE_DELAY = 200; + + private const double hide_duration = 800; + private const double arrange_duration = 1000; + private const double roll_duration = 4000; + private const double present_beatmap_delay = 1200; + private const float panel_spacing = 4; + + public event Action? ItemSelected; + + private readonly Dictionary panelLookup = new Dictionary(); + private readonly Dictionary playlistItems = new Dictionary(); + private MatchmakingSelectPanelRandom randomPanel = null!; + + private readonly PanelGridContainer panelGridContainer; + private readonly Container rollContainer; + private readonly OsuScrollContainer scroll; + + private bool allowSelection = true; + + private readonly Sample?[] spinSamples = new Sample?[5]; + private static readonly int[] spin_sample_sequence = [0, 1, 2, 3, 4, 2, 3, 4]; + private Sample? randomRevealSample; + private Sample? resultSample; + private Sample? swooshSample; + private double? lastSamplePlayback; + + public BeatmapSelectGrid() + { + InternalChildren = new Drawable[] + { + scroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = panelGridContainer = new PanelGridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(20), + Spacing = new Vector2(panel_spacing) + }, + }, + rollContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + for (int i = 0; i < spinSamples.Length; i++) + spinSamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Selection/roulette-{i}"); + + randomRevealSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/random-reveal"); + resultSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/roulette-result"); + swooshSample = audio.Samples.Get(@"SongSelect/options-pop-out"); + } + + public void AddItems(IEnumerable items) + { + foreach (var item in items) + { + playlistItems[item.ID] = item; + + var panel = panelLookup[item.ID] = new MatchmakingSelectPanelBeatmap(item) + { + AllowSelection = allowSelection, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = i => ItemSelected?.Invoke(i), + }; + + panelGridContainer.Add(panel); + panelGridContainer.SetLayoutPosition(panel, (float)panel.Item.StarRating); + } + + panelLookup[-1] = randomPanel = new MatchmakingSelectPanelRandom(new MultiplayerPlaylistItem { ID = -1 }) + { + AllowSelection = allowSelection, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = i => ItemSelected?.Invoke(i), + }; + panelGridContainer.Add(randomPanel); + panelGridContainer.SetLayoutPosition(randomPanel, float.MinValue); + + const double enter_duration = 500; + + // the scroll container has a 1 frame delay until it receives the correct height for the scrollable area which leads to the scrollbar resizing awkwardly + // if we wait until the panels have entered we get to avoid having to see that and the scrollbar it will appear synchronized with the rest of the content as a bonus + Scheduler.AddDelayed(() => scroll.ScrollbarVisible = true, enter_duration); + + SchedulerAfterChildren.Add(() => + { + foreach (var panel in panelGridContainer) + { + double delay = panel.Y / 3; + + panel.FadeInAndEnterFromBelow(duration: enter_duration, delay: delay); + } + + panelsLoaded.SetResult(); + }); + } + + public void SetUserSelection(APIUser user, long itemId, bool selected) => whenPanelsLoaded(() => + { + if (!panelLookup.TryGetValue(itemId, out var panel)) + return; + + if (selected) + panel.AddUser(user); + else + panel.RemoveUser(user); + }); + + public void RevealRandomItem(MultiplayerPlaylistItem item) => whenPanelsLoaded(() => + { + playlistItems.TryGetValue(item.ID, out var playlistItem); + + Debug.Assert(playlistItem != null); + + randomRevealSample?.Play(); + randomPanel.RevealBeatmap(playlistItem.Beatmap, playlistItem.Mods); + }); + + public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long finalItemId) => whenPanelsLoaded(() => + { + Debug.Assert(candidateItemIds.Length >= 1); + Debug.Assert(candidateItemIds.Contains(finalItemId)); + Debug.Assert(panelLookup.ContainsKey(finalItemId)); + Debug.Assert(candidateItemIds.All(id => panelLookup.ContainsKey(id))); + + allowSelection = false; + + TransferCandidatePanelsToRollContainer(candidateItemIds); + + if (candidateItemIds.Length == 1) + { + this.Delay(ARRANGE_DELAY) + .Schedule(() => ArrangeItemsForRollAnimation()) + .Delay(arrange_duration + present_beatmap_delay) + .Schedule(() => PresentUnanimouslyChosenBeatmap(finalItemId)); + } + else + { + this.Delay(ARRANGE_DELAY) + .Schedule(() => ArrangeItemsForRollAnimation()) + .Delay(arrange_duration) + .Schedule(() => PlayRollAnimation(finalItemId, roll_duration)) + .Delay(roll_duration + present_beatmap_delay) + .Schedule(() => PresentRolledBeatmap(finalItemId)); + } + }); + + internal void TransferCandidatePanelsToRollContainer(long[] candidateItemIds, double duration = hide_duration) + { + scroll.ScrollbarVisible = false; + panelGridContainer.LayoutDisabled = true; + + var rng = new Random(); + + var remainingPanels = new List(); + + foreach (var panel in panelGridContainer.Children.ToArray()) + { + panel.AllowSelection = false; + + if (!candidateItemIds.Contains(panel.Item.ID)) + { + panel.PopOutAndExpire(duration: duration / 2, delay: rng.NextDouble() * duration / 2); + continue; + } + + remainingPanels.Add(panel); + } + + rng.Shuffle(remainingPanels.AsSpan()); + + foreach (var panel in remainingPanels) + { + var position = panel.ScreenSpaceDrawQuad.Centre; + + panelGridContainer.Remove(panel, false); + + panel.Anchor = panel.Origin = Anchor.Centre; + panel.Position = rollContainer.ToLocalSpace(position) - rollContainer.ChildSize / 2; + + rollContainer.Add(panel); + } + } + + internal void ArrangeItemsForRollAnimation(double duration = arrange_duration, double stagger = 30) + { + var positions = calculateLayoutPositionsForRollAnimation(rollContainer.Children.Count); + + Debug.Assert(positions.Length == rollContainer.Children.Count); + + for (int i = 0; i < positions.Length; i++) + { + var panel = rollContainer.Children[i]; + + var position = positions[i] * (MatchmakingSelectPanel.SIZE + new Vector2(panel_spacing)); + + panel.MoveTo(position, duration + stagger * i, new SplitEasingFunction(Easing.InCubic, Easing.OutExpo, 0.3f)); + + Scheduler.AddDelayed(() => + { + var chan = swooshSample?.GetChannel(); + if (chan == null) return; + + chan.Frequency.Value = 1.25f - RNG.NextDouble(0.5f); + chan.Play(); + }, stagger * i); + } + } + + private static Vector2[] calculateLayoutPositionsForRollAnimation(int panelCount) + { + if (panelCount == 1) + return new[] { Vector2.Zero }; + + // goal is to get the positions arranged in clockwise order, with the top-left position being the first one + // to keep things simple the positions are first inserted in the order: right row, optional bottom center panel, left row backwards + // then the positions get shifted by 1 to move the top-left position into the first spot + + bool hasCenterPanel = panelCount % 2 == 1; + int rowCount = (panelCount + 1) / 2; + int outerRowCount = hasCenterPanel ? rowCount - 1 : rowCount; + + float yOffset = -(rowCount - 1f) / 2; + + var positions = new Vector2[panelCount]; + + for (int row = 0; row < outerRowCount; row++) + { + positions[row] = new Vector2(0.5f, row + yOffset); + } + + if (hasCenterPanel) + { + int centerIndex = panelCount / 2; + + positions[centerIndex] = new Vector2(0, outerRowCount + yOffset); + } + + for (int row = 0; row < outerRowCount; row++) + { + int index = positions.Length - 1 - row; + + positions[index] = new Vector2(-0.5f, row + yOffset); + } + + return positions.TakeLast(1).Concat(positions.SkipLast(1)).ToArray(); + } + + internal void PlayRollAnimation(long finalItem, double duration = roll_duration) + { + const int minimum_steps = 20; + + int finalItemIndex = rollContainer.Children + .Select(it => it.Item.ID) + .ToImmutableList() + .IndexOf(finalItem); + + Debug.Assert(finalItemIndex >= 0); + + int numSteps = minimum_steps; + while ((numSteps - 1) % rollContainer.Children.Count != finalItemIndex) + numSteps++; + + MatchmakingSelectPanel? lastPanel = null; + + for (int i = 0; i < numSteps; i++) + { + float progress = ((float)i) / (numSteps - 1); + + double delay = Math.Pow(progress, 2.5) * duration; + var panel = rollContainer.Children[i % rollContainer.Children.Count]; + + int ii = i; + Scheduler.AddDelayed(() => + { + lastPanel?.HideBorder(); + panel.ShowBorder(); + + if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME) + { + int sequenceIdx = ii % spin_sample_sequence.Length; + spinSamples[spin_sample_sequence[sequenceIdx]]?.Play(); + lastSamplePlayback = Time.Current; + } + + lastPanel = panel; + }, delay); + } + } + + internal void PresentRolledBeatmap(long finalItem) + { + Debug.Assert(rollContainer.Children.Any(it => it.Item.ID == finalItem)); + + foreach (var panel in rollContainer.Children) + { + if (panel.Item.ID != finalItem) + { + panel.FadeOut(200); + panel.PopOutAndExpire(easing: Easing.InQuad); + continue; + } + + // if we changed child depth without scheduling we'd change the order of the panels while iterating + Schedule(() => + { + rollContainer.ChangeChildDepth(panel, float.MinValue); + + panel.ShowChosenBorder(); + panel.MoveTo(Vector2.Zero, 1000, Easing.OutExpo) + .ScaleTo(1.5f, 1000, Easing.OutExpo); + + resultSample?.Play(); + }); + } + } + + internal void PresentUnanimouslyChosenBeatmap(long finalItem) + { + // TODO: display special animation in this case + + PresentRolledBeatmap(finalItem); + } + + private readonly TaskCompletionSource panelsLoaded = new TaskCompletionSource(); + + private void whenPanelsLoaded(Action action) => Task.Run(async () => + { + await panelsLoaded.Task.ConfigureAwait(false); + Schedule(action); + }); + + private partial class PanelGridContainer : FillFlowContainer + { + public bool LayoutDisabled; + + protected override IEnumerable ComputeLayoutPositions() + { + if (LayoutDisabled) + return FlowingChildren.Select(c => c.Position); + + return base.ComputeLayoutPositions(); + } + } + + private readonly struct SplitEasingFunction(DefaultEasingFunction easeIn, DefaultEasingFunction easeOut, float ratio) : IEasingFunction + { + public SplitEasingFunction(Easing easeIn, Easing easeOut, float ratio = 0.5f) + : this(new DefaultEasingFunction(easeIn), new DefaultEasingFunction(easeOut), ratio) + { + } + + public double ApplyEasing(double time) + { + if (time < ratio) + return easeIn.ApplyEasing(time / ratio) * ratio; + + return double.Lerp(ratio, 1, easeOut.ApplyEasing((time - ratio) / (1 - ratio))); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingPlaylistItem.cs new file mode 100644 index 0000000000..6b7fb9f21e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingPlaylistItem.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 osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public record MatchmakingPlaylistItem(MultiplayerPlaylistItem PlaylistItem, APIBeatmap Beatmap, Mod[] Mods) + { + public long ID => PlaylistItem.ID; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.cs new file mode 100644 index 0000000000..48c64f2f66 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContent.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.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.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanel + { + public abstract partial class CardContent : CompositeDrawable + { + public abstract AvatarOverlay SelectionOverlay { get; } + + protected CardContent() + { + RelativeSizeAxes = Axes.Both; + } + + public partial class AvatarOverlay : CompositeDrawable + { + private readonly Container avatars; + + private Sample? userAddedSample; + private double? lastSamplePlayback; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public AvatarOverlay() + { + AutoSizeAxes = Axes.Both; + + InternalChild = avatars = new Container + { + AutoSizeAxes = Axes.X, + Height = SelectionAvatar.AVATAR_SIZE, + }; + + Padding = new MarginPadding { Vertical = 5 }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + userAddedSample = audio.Samples.Get(@"Multiplayer/player-ready"); + } + + public bool AddUser(APIUser user) + { + if (avatars.Any(a => a.User.Id == user.Id)) + return false; + + var avatar = new SelectionAvatar(user, user.Equals(api.LocalUser.Value)); + + avatars.Add(avatar); + + if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME) + { + userAddedSample?.Play(); + lastSamplePlayback = Time.Current; + } + + updateAvatarLayout(); + + avatar.FinishTransforms(); + + return true; + } + + public bool RemoveUser(int id) + { + if (avatars.SingleOrDefault(a => a.User.Id == id) is not SelectionAvatar avatar) + return false; + + avatar.PopOutAndExpire(); + avatars.ChangeChildDepth(avatar, float.MaxValue); + + updateAvatarLayout(); + + return true; + } + + private void updateAvatarLayout() + { + const double stagger = 30; + const float spacing = 4; + + double delay = 0; + float x = 0; + + for (int i = avatars.Count - 1; i >= 0; i--) + { + var avatar = avatars[i]; + + if (avatar.Expired) + continue; + + avatar.Delay(delay).MoveToX(x, 500, Easing.OutElasticQuarter); + + x -= avatar.LayoutSize.X + spacing; + + delay += stagger; + } + } + + public partial class SelectionAvatar : CompositeDrawable + { + public const float AVATAR_SIZE = 30; + + public APIUser User { get; } + + public bool Expired { get; private set; } + + private readonly MatchmakingAvatar avatar; + + public SelectionAvatar(APIUser user, bool isOwnUser) + { + User = user; + Size = new Vector2(AVATAR_SIZE); + + InternalChild = avatar = new MatchmakingAvatar(user, isOwnUser) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + avatar.ScaleTo(0) + .ScaleTo(1, 500, Easing.OutElasticHalf) + .FadeIn(200); + } + + public void PopOutAndExpire() + { + avatar.ScaleTo(0, 400, Easing.OutExpo); + + this.FadeOut(100).Expire(); + Expired = true; + } + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs new file mode 100644 index 0000000000..b27ab2850b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs @@ -0,0 +1,381 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.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.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapSet; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanel + { + public partial class CardContentBeatmap : CardContent, IHasContextMenu + { + public override AvatarOverlay SelectionOverlay => selectionOverlay; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private BeatmapSetOverlay? beatmapSetOverlay { get; set; } + + private readonly IBindable downloadState = new Bindable(); + private readonly IBindableNumber downloadProgress = new BindableDouble(); + private readonly Bindable favouriteState = new Bindable(); + private readonly APIBeatmapSet beatmapSet; + private readonly APIBeatmap beatmap; + private readonly Mod[] mods; + + private BeatmapCardThumbnail thumbnail = null!; + private CollapsibleButtonContainer buttonContainer = null!; + private FillFlowContainer idleBottomContent = null!; + private BeatmapCardDownloadProgressBar downloadProgressBar = null!; + private AvatarOverlay selectionOverlay = null!; + + public CardContentBeatmap(APIBeatmap beatmap, Mod[] mods) + { + this.beatmap = beatmap; + this.mods = mods; + + beatmapSet = beatmap.BeatmapSet!; + favouriteState.Value = new BeatmapSetFavouriteState(beatmapSet.HasFavourited, beatmapSet.FavouriteCount); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + FillFlowContainer leftIconArea; + FillFlowContainer titleBadgeArea; + GridContainer artistContainer; + + InternalChildren = new Drawable[] + { + new BeatmapDownloadTracker(beatmap.BeatmapSet!) + { + State = { BindTarget = downloadState }, + Progress = { BindTarget = downloadProgress }, + }, + thumbnail = new BeatmapCardThumbnail(beatmapSet, beatmapSet, keepLoaded: true) + { + Name = @"Left (icon) area", + Size = new Vector2(MatchmakingSelectPanel.HEIGHT), + Padding = new MarginPadding { Right = BeatmapCard.CORNER_RADIUS }, + Child = leftIconArea = new FillFlowContainer + { + Margin = new MarginPadding(4), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1) + } + }, + buttonContainer = new CollapsibleButtonContainer(beatmapSet, allowNavigationToBeatmap: false, keepBackgroundLoaded: true) + { + X = MatchmakingSelectPanel.HEIGHT - BeatmapCard.CORNER_RADIUS, + Width = BeatmapCard.WIDTH - MatchmakingSelectPanel.HEIGHT + BeatmapCard.CORNER_RADIUS, + FavouriteState = { BindTarget = favouriteState }, + ButtonsCollapsedWidth = 0, + ButtonsExpandedWidth = 24, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new TruncatingSpriteText + { + Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title), + Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + }, + titleBadgeArea = new FillFlowContainer + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + } + } + } + }, + artistContainer = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] + { + new TruncatingSpriteText + { + Text = BeatmapsetsStrings.ShowDetailsByArtist(new RomanisableString(beatmapSet.ArtistUnicode, beatmapSet.Artist)), + Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + }, + Empty() + }, + } + }, + new LinkFlowContainer(s => + { + s.Shadow = false; + s.Font = OsuFont.GetFont(size: 11f, weight: FontWeight.SemiBold); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.Margin = new MarginPadding { Top = 1 }; + d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); + d.AddUserLink(beatmapSet.Author); + }), + } + }, + new Container + { + Name = @"Bottom content", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Children = new Drawable[] + { + idleBottomContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + AlwaysPresent = true, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + Colour = colours.ForStarDifficulty(beatmap.StarRating).Darken(0.8f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Padding = new MarginPadding(4), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(6, 0), + Children = new Drawable[] + { + new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small, animated: true) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.9f), + }, + new TruncatingSpriteText + { + Text = beatmap.DifficultyName, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + } + }, + new ModFlowDisplay + { + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.5f), + Margin = new MarginPadding { Left = 5 }, + Current = { Value = mods } + }, + }, + } + }, + } + }, + downloadProgressBar = new BeatmapCardDownloadProgressBar + { + RelativeSizeAxes = Axes.X, + Height = 5, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { BindTarget = downloadState }, + Progress = { BindTarget = downloadProgress } + } + } + }, + selectionOverlay = new AvatarOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + } + } + } + }; + + if (beatmapSet.HasVideo) + leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) }); + + if (beatmapSet.HasStoryboard) + leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) }); + + if (beatmapSet.FeaturedInSpotlight) + { + titleBadgeArea.Add(new SpotlightBeatmapBadge + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Left = 4 } + }); + } + + if (beatmapSet.HasExplicitContent) + { + titleBadgeArea.Add(new ExplicitContentBeatmapBadge + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Left = 4 } + }); + } + + if (beatmapSet.TrackId != null) + { + artistContainer.Content[0][1] = new FeaturedArtistBeatmapBadge + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Left = 4 } + }; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + downloadState.BindValueChanged(_ => updateState(), true); + + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + bool showDetails = IsHovered; + + buttonContainer.ShowDetails.Value = showDetails; + thumbnail.Dimmed.Value = showDetails; + + bool showProgress = downloadState.Value == DownloadState.Downloading || downloadState.Value == DownloadState.Importing; + + idleBottomContent.FadeTo(showProgress ? 0 : 1, 340, Easing.OutQuint); + downloadProgressBar.FadeTo(showProgress ? 1 : 0, 340, Easing.OutQuint); + } + + public MenuItem[] ContextMenuItems + { + get + { + List items = new List + { + new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, () => beatmapSetOverlay?.FetchAndShowBeatmap(beatmap.OnlineID)) + }; + + foreach (var button in buttonContainer.Buttons) + { + if (button.Enabled.Value) + items.Add(new OsuMenuItem(button.TooltipText.ToSentence(), MenuItemType.Standard, () => button.TriggerClick())); + } + + return items.ToArray(); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs new file mode 100644 index 0000000000..24422de1b5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.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.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanel + { + public partial class CardContentRandom : CardContent + { + public override AvatarOverlay SelectionOverlay => selectionOverlay; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private AvatarOverlay selectionOverlay = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background2, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = 10, + Vertical = 4 + }, + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = + [ + new SpriteIcon + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(32), + Icon = FontAwesome.Solid.Random, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Random", + } + ] + }, + selectionOverlay = new AvatarOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs new file mode 100644 index 0000000000..ca10133a36 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs @@ -0,0 +1,206 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public abstract partial class MatchmakingSelectPanel : Container + { + public const float WIDTH = 345; + public const float HEIGHT = 80; + + public static readonly Vector2 SIZE = new Vector2(WIDTH, HEIGHT); + + public bool AllowSelection { get; set; } + + public readonly MultiplayerPlaylistItem Item; + + public Action? Action { private get; init; } + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private const float border_width = 3; + + private Container scaleContainer = null!; + private Drawable lighting = null!; + private Container border = null!; + + protected MatchmakingSelectPanel(MultiplayerPlaylistItem item) + { + Item = item; + Size = SIZE; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChildren = new Drawable[] + { + scaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + new Container + { + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + CornerExponent = 10, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + Content, + lighting = new Box + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + } + }, + border = new Container + { + Alpha = 0, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + CornerExponent = 10, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + BorderThickness = border_width, + BorderColour = colourProvider.Light1, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 40, + Roundness = 300, + Colour = colourProvider.Light3.Opacity(0.1f), + }, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + } + }, + } + }, + new HoverClickSounds(), + }; + } + + // TODO: making these abstract for now but avatar overlay should really be owned by the top level class + public abstract void AddUser(APIUser user); + + public abstract void RemoveUser(APIUser user); + + protected override bool OnHover(HoverEvent e) + { + if (AllowSelection) + { + lighting.FadeTo(0.2f, 50) + .Then() + .FadeTo(0.1f, 300); + return true; + } + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + + lighting.FadeOut(200); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (AllowSelection && e.Button == MouseButton.Left) + scaleContainer.ScaleTo(0.95f, 400, Easing.OutExpo); + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + base.OnMouseUp(e); + + if (e.Button == MouseButton.Left) + scaleContainer.ScaleTo(1f, 500, Easing.OutElasticHalf); + } + + protected override bool OnClick(ClickEvent e) + { + if (AllowSelection) + { + lighting.FadeTo(0.5f, 50) + .Then() + .FadeTo(0.1f, 400); + + Action?.Invoke(Item); + } + + return true; + } + + public void ShowChosenBorder() + { + border.FadeTo(1, 1000, Easing.OutQuint); + } + + public void ShowBorder() + { + border.FadeTo(1, 80, Easing.OutQuint) + .Then() + .FadeTo(0.7f, 800, Easing.OutQuint); + } + + public void HideBorder() + { + border.FadeOut(500, Easing.OutQuint); + } + + public void FadeInAndEnterFromBelow(double duration = 500, double delay = 0, float distance = 200) + { + scaleContainer + .FadeOut() + .MoveToY(distance) + .Delay(delay) + .FadeIn(duration / 2) + .MoveToY(0, duration, Easing.OutExpo); + } + + public void PopOutAndExpire(double duration = 400, double delay = 0, Easing easing = Easing.InCubic) + { + AllowSelection = false; + + scaleContainer.Delay(delay) + .ScaleTo(0, duration, easing) + .FadeOut(duration); + + this.Delay(delay + duration).FadeOut().Expire(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelBeatmap.cs new file mode 100644 index 0000000000..ec00ed3847 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelBeatmap.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.Allocation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanelBeatmap : MatchmakingSelectPanel + { + private readonly APIBeatmap beatmap; + private readonly Mod[] mods; + + public MatchmakingSelectPanelBeatmap(MatchmakingPlaylistItem item) + : base(item.PlaylistItem) + { + beatmap = item.Beatmap; + mods = item.Mods; + } + + private CardContent content = null!; + + [BackgroundDependencyLoader] + private void load() + { + Add(content = new CardContentBeatmap(beatmap, mods)); + } + + public override void AddUser(APIUser user) + { + content.SelectionOverlay.AddUser(user); + } + + public override void RemoveUser(APIUser user) + { + content.SelectionOverlay.RemoveUser(user.Id); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs new file mode 100644 index 0000000000..0c818df06b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs @@ -0,0 +1,60 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class MatchmakingSelectPanelRandom : MatchmakingSelectPanel + { + public MatchmakingSelectPanelRandom(MultiplayerPlaylistItem item) + : base(item) + { + } + + private CardContent content = null!; + private readonly List users = new List(); + + [BackgroundDependencyLoader] + private void load() + { + Add(content = new CardContentRandom()); + } + + public void RevealBeatmap(APIBeatmap beatmap, Mod[] mods) + { + content.Expire(); + + var flashLayer = new Box { RelativeSizeAxes = Axes.Both }; + + AddRange(new Drawable[] + { + content = new CardContentBeatmap(beatmap, mods), + flashLayer, + }); + + foreach (var user in users) + content.SelectionOverlay.AddUser(user); + + flashLayer.FadeOutFromOne(1000, Easing.In); + } + + public override void AddUser(APIUser user) + { + users.Add(user); + content.SelectionOverlay.AddUser(user); + } + + public override void RemoveUser(APIUser user) + { + users.Remove(user); + content.SelectionOverlay.RemoveUser(user.Id); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs new file mode 100644 index 0000000000..7951fc5448 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect +{ + public partial class SubScreenBeatmapSelect : MatchmakingSubScreen + { + public override PanelDisplayStyle PlayersDisplayStyle => PanelDisplayStyle.Split; + public override Drawable PlayersDisplayArea { get; } + + private readonly BeatmapSelectGrid beatmapSelectGrid; + private readonly LoadingSpinner loadingSpinner; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + public SubScreenBeatmapSelect() + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 200 }, + Children = new Drawable[] + { + beatmapSelectGrid = new BeatmapSelectGrid + { + RelativeSizeAxes = Axes.Both, + }, + loadingSpinner = new LoadingSpinner + { + Size = new Vector2(64), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { Value = Visibility.Visible } + } + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Child = PlayersDisplayArea = new Container().With(d => + { + d.RelativeSizeAxes = Axes.Both; + }) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmapSelectGrid.ItemSelected += item => client.MatchmakingToggleSelection(item.ID); + client.MatchmakingItemSelected += onItemSelected; + client.MatchmakingItemDeselected += onItemDeselected; + client.SettingsChanged += onSettingsChanged; + + Debug.Assert(client.Room != null); + + loadItems(client.Room.Playlist.ToArray()).FireAndForget(); + } + + private async Task loadItems(MultiplayerPlaylistItem[] items) + { + var beatmaps = await beatmapLookupCache.GetBeatmapsAsync(items.Select(it => it.BeatmapID).ToArray()).ConfigureAwait(false); + var matchmakingItems = new List(); + + foreach (var entry in items.Zip(beatmaps)) + { + var (item, beatmap) = entry; + + beatmap ??= new APIBeatmap + { + BeatmapSet = new APIBeatmapSet + { + Title = "unknown beatmap", + TitleUnicode = "unknown beatmap", + Artist = "unknown artist", + ArtistUnicode = "unknown artist", + } + }; + + beatmap.StarRating = item.StarRating; + + Ruleset? ruleset = rulesetStore.GetRuleset(item.RulesetID)?.CreateInstance(); + + Debug.Assert(ruleset != null); + + Mod[] mods = item.RequiredMods.Select(m => m.ToMod(ruleset)).ToArray(); + + matchmakingItems.Add(new MatchmakingPlaylistItem(item, beatmap, mods)); + } + + Scheduler.Add(() => + { + loadingSpinner.Hide(); + beatmapSelectGrid.AddItems(matchmakingItems); + }); + } + + private void onItemSelected(int userId, long itemId) + { + var user = client.Room!.Users.First(it => it.UserID == userId).User!; + beatmapSelectGrid.SetUserSelection(user, itemId, true); + } + + private void onItemDeselected(int userId, long itemId) + { + var user = client.Room!.Users.First(it => it.UserID == userId).User!; + beatmapSelectGrid.SetUserSelection(user, itemId, false); + } + + private void onSettingsChanged(MultiplayerRoomSettings settings) + { + if (client.Room!.MatchState is not MatchmakingRoomState matchmakingState) + return; + + if (matchmakingState.Stage != MatchmakingStage.ServerBeatmapFinalised) + return; + + if (matchmakingState.CandidateItem != -1) + return; + + beatmapSelectGrid.RevealRandomItem(client.Room!.CurrentPlaylistItem); + } + + public void RollFinalBeatmap(long[] candidateItems, long finalItem) => beatmapSelectGrid.RollAndDisplayFinalBeatmap(candidateItems, finalItem); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.MatchmakingItemSelected -= onItemSelected; + client.MatchmakingItemDeselected -= onItemDeselected; + client.SettingsChanged -= onSettingsChanged; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Gameplay/ScreenGameplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Gameplay/ScreenGameplay.cs new file mode 100644 index 0000000000..f6f324eb90 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Gameplay/ScreenGameplay.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.Threading.Tasks; +using osu.Framework.Screens; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Multiplayer; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Gameplay +{ + public partial class ScreenGameplay : MultiplayerPlayer + { + public ScreenGameplay(Room room, PlaylistItem playlistItem, MultiplayerRoomUser[] users) + : base(room, playlistItem, users) + { + } + + protected override async Task PrepareScoreForResultsAsync(Score score) + { + await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); + + Scheduler.Add(() => + { + if (this.IsCurrentScreen()) + this.Exit(); + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs new file mode 100644 index 0000000000..e0f46d89f0 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + /// + /// A circular player avatar used in matchmaking displays. + /// Is part of a but can also be used in isolation for a more ambient/decorative user display. + /// + public partial class MatchmakingAvatar : CompositeDrawable + { + public static readonly Vector2 SIZE = new Vector2(30); + + private readonly APIUser user; + private readonly bool isOwnUser; + + public MatchmakingAvatar(APIUser user, bool isOwnUser = false) + { + this.user = user; + this.isOwnUser = isOwnUser; + + Size = SIZE; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + if (isOwnUser) + { + AddInternal(new Container + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Child = new FastCircle + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Yellow, + } + }); + } + + AddInternal(new Container + { + Padding = new MarginPadding(2), + RelativeSizeAxes = Axes.Both, + Child = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.LightSlateGray, + }, + new ClickableAvatar(user, true) + { + RelativeSizeAxes = Axes.Both, + } + } + } + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingChatDisplay.cs new file mode 100644 index 0000000000..6a01642907 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingChatDisplay.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input; +using osu.Game.Input.Bindings; +using osu.Game.Online.Rooms; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.OnlinePlay.Match.Components; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class MatchmakingChatDisplay : MatchChatDisplay, IKeyBindingHandler + { + protected new ChatTextBox TextBox => base.TextBox!; + + public MatchmakingChatDisplay(Room room, bool leaveChannelOnDispose = true) + : base(room, leaveChannelOnDispose) + { + } + + [BackgroundDependencyLoader] + private void load(RealmKeyBindingStore keyBindingStore) + { + resetPlaceholderText(); + + TextBox.HoldFocus = false; + TextBox.ReleaseFocusOnCommit = true; + TextBox.Focus = () => TextBox.PlaceholderText = ChatStrings.InputPlaceholder; + TextBox.FocusLost = resetPlaceholderText; + + void resetPlaceholderText() => TextBox.PlaceholderText = Localisation.ChatStrings.InGameInputPlaceholder(keyBindingStore.GetBindingsStringFor(GlobalAction.ToggleChatFocus)); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.Back: + if (TextBox.HasFocus) + { + Schedule(() => TextBox.KillFocus()); + return true; + } + + break; + + case GlobalAction.ToggleChatFocus: + if (TextBox.HasFocus) + { + Schedule(() => TextBox.KillFocus()); + } + else + { + Schedule(() => TextBox.TakeFocus()); + } + + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + public void Appear() + { + FinishTransforms(); + + this.MoveToY(150f) + .FadeOut() + .MoveToY(0f, 240, Easing.OutCubic) + .FadeIn(240, Easing.OutCubic); + } + + public TransformSequence Disappear() + { + FinishTransforms(); + + return this.FadeOut(240, Easing.InOutCubic) + .MoveToY(150f, 240, Easing.InOutCubic); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingSubScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingSubScreen.cs new file mode 100644 index 0000000000..0141c424bd --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingSubScreen.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Screens; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public abstract partial class MatchmakingSubScreen : Screen + { + public abstract PanelDisplayStyle PlayersDisplayStyle { get; } + public abstract Drawable? PlayersDisplayArea { get; } + + protected MatchmakingSubScreen() + { + RelativePositionAxes = Axes.X; + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + this.FadeInFromZero(200); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + this.FadeOutFromOne(200); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + this.FadeInFromZero(200); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + this.FadeOutFromOne(200); + return false; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs new file mode 100644 index 0000000000..0940fbdabb --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs @@ -0,0 +1,636 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.ObjectExtensions; +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.Events; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Matchmaking.Events; +using osu.Game.Online.Metadata; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results; +using osu.Game.Screens.Play; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + /// + /// A panel used throughout matchmaking to represent a user, including local information like their + /// rank and high level statistics in the matchmaking system. + /// + public partial class PlayerPanel : OsuClickableContainer, IHasContextMenu + { + private static readonly Vector2 size_horizontal = new Vector2(250, 100); + private static readonly Vector2 size_vertical = new Vector2(150, 200); + private static readonly Vector2 avatar_size = new Vector2(80); + + public readonly MultiplayerRoomUser RoomUser; + + /// + /// Perform an action in addition to showing the user's profile. + /// This should be used to perform auxiliary tasks and not as a primary action for clicking a user panel (to maintain a consistent UX). + /// + public new Action? Action; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private UserProfileOverlay? profileOverlay { get; set; } + + [Resolved] + private ChannelManager? channelManager { get; set; } + + [Resolved] + private ChatOverlay? chatOverlay { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private OverlayColourProvider? colourProvider { get; set; } + + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private MultiplayerClient? multiplayerClient { get; set; } + + [Resolved] + private MetadataClient? metadataClient { get; set; } + + public readonly APIUser User; + private readonly Action viewProfile; + + private OsuSpriteText rankText = null!; + private OsuSpriteText scoreText = null!; + + private Drawable avatarPositionTarget = null!; + private Drawable avatarJumpTarget = null!; + private Drawable avatar = null!; + private OsuSpriteText username = null!; + + private Container mainContent = null!; + + private Box solidBackgroundLayer = null!; + private Drawable background = null!; + + private OsuSpriteText quitText = null!; + private BufferedContainer backgroundQuitTarget = null!; + private BufferedContainer avatarQuitTarget = null!; + + private Box downloadProgressBar = null!; + + private PlayerPanelDisplayMode displayMode = PlayerPanelDisplayMode.Horizontal; + private bool hasQuit; + + private enum InteractionSampleType + { + PlayerJump, + PlayerReJump, + OtherPlayerJump, + } + + private Dictionary interactionSamples = new Dictionary(); + private readonly Dictionary interactionSampleChannels = new Dictionary(); + private double samplePitch; + private double? lastSamplePlayback; + + public PlayerPanel(MultiplayerRoomUser user) + : base(HoverSampleSet.Button) + { + ArgumentNullException.ThrowIfNull(user.User); + + User = user.User; + RoomUser = user; + + base.Action = viewProfile = () => + { + Action?.Invoke(); + profileOverlay?.ShowUser(User); + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + Content.Masking = true; + Content.CornerRadius = 10; + Content.CornerExponent = 10; + Content.Anchor = Anchor.Centre; + Content.Origin = Anchor.Centre; + + Child = backgroundQuitTarget = new BufferedContainer + { + FrameBufferScale = new Vector2(1.5f), + RelativeSizeAxes = Axes.Both, + Children = new[] + { + solidBackgroundLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background5 ?? colours.Gray1 + }, + background = new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colours.Gray7, + User = User + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + mainContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + quitText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "QUIT", + Font = OsuFont.Default.With(weight: "Bold", size: 70), + Rotation = -22.5f, + Colour = OsuColour.Gray(0.3f), + Blending = BlendingParameters.Additive + }, + avatarPositionTarget = new Container + { + Origin = Anchor.Centre, + Size = avatar_size, + Child = avatarJumpTarget = new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Child = avatar = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + // Needs to be re-buffered as the avatar is proxied outside of the parent buffered container. + Child = avatarQuitTarget = new BufferedContainer + { + FrameBufferScale = new Vector2(1.5f), + RelativeSizeAxes = Axes.Both, + Child = new MatchmakingAvatar(User, isOwnUser: User.Id == api.LocalUser.Value.Id) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = Vector2.One + } + } + }, + } + }, + rankText = new OsuSpriteText + { + Alpha = 0, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomCentre, + Blending = BlendingParameters.Additive, + Margin = new MarginPadding(4), + Text = "-", + Font = OsuFont.Style.Title.With(size: 55), + }, + username = new OsuSpriteText + { + Alpha = 0, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Text = User.Username, + Font = OsuFont.Style.Heading1, + }, + scoreText = new OsuSpriteText + { + Alpha = 0, + Margin = new MarginPadding(10), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.Style.Heading2, + Text = "0 pts" + } + } + }, + downloadProgressBar = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Size = new Vector2(0, 4), + Colour = colourProvider?.Content2 ?? colours.Gray3 + } + } + } + } + }; + + // Allow avatar to exist outside of masking for when it jumps around and stuff. + AddInternal(avatar.CreateProxy()); + + interactionSamples = new Dictionary + { + { InteractionSampleType.PlayerJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-jump") }, + { InteractionSampleType.PlayerReJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-rejump") }, + { InteractionSampleType.OtherPlayerJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-jump-other") } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateLayout(true); + + client.MatchRoomStateChanged += onRoomStateChanged; + client.MatchEvent += onMatchEvent; + client.BeatmapAvailabilityChanged += onBeatmapAvailabilityChanged; + + onRoomStateChanged(client.Room!.MatchState); + + avatar.ScaleTo(0) + .ScaleTo(1, 500, Easing.OutElasticHalf) + .FadeIn(200); + + // pick a random pitch to be used by the player for duration of this session + samplePitch = 0.75f + RNG.NextDouble(0f, 0.75f); + } + + public PlayerPanelDisplayMode DisplayMode + { + get => displayMode; + set + { + displayMode = value; + if (IsLoaded) + updateLayout(false); + } + } + + public bool HasQuit + { + get => hasQuit; + set + { + hasQuit = value; + if (IsLoaded) + updateLayout(false); + } + } + + private bool horizontal => displayMode == PlayerPanelDisplayMode.Horizontal; + + private Vector2 avatarPosition + { + get + { + switch (displayMode) + { + case PlayerPanelDisplayMode.AvatarOnly: + return avatar_size / 2; + + case PlayerPanelDisplayMode.Horizontal: + return new Vector2(50); + + case PlayerPanelDisplayMode.Vertical: + return new Vector2(75, 50); + + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private void updateLayout(bool instant) + { + double duration = instant ? 0 : 1000; + + avatarPositionTarget.MoveTo(avatarPosition, duration, Easing.OutPow10); + + switch (displayMode) + { + case PlayerPanelDisplayMode.AvatarOnly: + rankText.Hide(); + scoreText.Hide(); + username.Hide(); + + background.FadeOut(200, Easing.OutQuint); + solidBackgroundLayer.FadeOut(200, Easing.OutQuint); + + this.ResizeTo(avatar_size, duration, Easing.OutPow10); + break; + + case PlayerPanelDisplayMode.Horizontal: + case PlayerPanelDisplayMode.Vertical: + background.FadeIn(200); + solidBackgroundLayer.FadeIn(200); + + using (BeginDelayedSequence(100)) + { + username.FadeIn(600); + + using (BeginDelayedSequence(100)) + { + scoreText.FadeIn(600); + + using (BeginDelayedSequence(100)) + { + rankText.FadeTo(1, 600); + } + } + } + + this.ResizeTo(horizontal ? size_horizontal : size_vertical, duration, Easing.OutPow10); + + rankText.MoveTo(horizontal ? new Vector2(-40, -20) : new Vector2(-70, 0), duration, Easing.OutPow10); + username.MoveTo(horizontal ? new Vector2(0, -46) : new Vector2(0, -86), duration, Easing.OutPow10); + scoreText.MoveTo(horizontal ? new Vector2(0, -16) : new Vector2(0, -56), duration, Easing.OutPow10); + quitText.MoveTo(horizontal ? new Vector2(40, 0) : new Vector2(0, 40), duration, Easing.OutPow10); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + // quit text doesn't fit on avataronly mode. + if (HasQuit && displayMode != PlayerPanelDisplayMode.AvatarOnly) + quitText.FadeIn(duration, Easing.OutPow10); + else + quitText.FadeOut(duration, Easing.OutPow10); + + if (HasQuit) + { + backgroundQuitTarget.GrayscaleTo(1, duration, Easing.OutPow10); + avatarQuitTarget.GrayscaleTo(1, duration, Easing.OutPow10); + } + else + { + backgroundQuitTarget.GrayscaleTo(0, duration, Easing.OutPow10); + avatarQuitTarget.GrayscaleTo(0, duration, Easing.OutPow10); + } + } + + protected override void Update() + { + base.Update(); + + // Not sure why this is required but it is. + avatarQuitTarget.Alpha = Alpha; + } + + protected override bool OnHover(HoverEvent e) + { + Content.ScaleTo(1.03f, 2000, Easing.OutPow10); + mainContent.ScaleTo(1.03f, 2000, Easing.OutPow10); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Content.ScaleTo(1f, 750, Easing.OutPow10); + mainContent.ScaleTo(1, 750, Easing.OutPow10); + + mainContent.MoveTo(Vector2.Zero, 1250, Easing.OutPow10); + avatarPositionTarget.MoveTo(avatarPosition, 1250, Easing.OutPow10); + base.OnHoverLost(e); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + var offset = (avatarPositionTarget.ToLocalSpace(e.ScreenSpaceMousePosition) - avatarPositionTarget.DrawSize / 2) * 0.02f; + + mainContent.MoveTo(offset * 0.5f, 2000, Easing.OutPow10); + avatarPositionTarget.MoveTo(avatarPosition + offset, 2000, Easing.OutPow10); + return base.OnMouseMove(e); + } + + private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + if (!matchmakingState.Users.UserDictionary.TryGetValue(User.Id, out MatchmakingUser? userScore)) + return; + + if (userScore.Placement == null) + return; + + rankText.Text = userScore.Placement.Value.Ordinalize(CultureInfo.CurrentCulture); + rankText.FadeColour(SubScreenResults.ColourForPlacement(userScore.Placement.Value)); + scoreText.Text = $"{userScore.Points} pts"; + }); + + private int consecutiveJumps; + + private void onMatchEvent(MatchServerEvent e) + { + switch (e) + { + case MatchmakingAvatarActionEvent action: + if (action.UserId != RoomUser.UserID) + break; + + switch (action.Action) + { + case MatchmakingAvatarAction.Jump: + var movement = avatarJumpTarget.Delay(0); + var scale = avatarJumpTarget.Delay(0); + + // only increase height if the user jumps again while in a "jumped" state. + // this avoids building up large jumps from very quick spam, and adds a timing game. + bool isConsecutive = avatarJumpTarget.Y < 0; + + if (isConsecutive) + { + consecutiveJumps++; + + if (avatarJumpTarget.Y > 0) + movement = movement.MoveToY(0); + + movement = movement.MoveToY(5, 100, Easing.Out); + scale = scale.ScaleTo(new Vector2(1, 0.95f), 100, Easing.Out); + } + else + { + consecutiveJumps = 0; + } + + float multiplier = 1 + 0.3f * Math.Min(10, consecutiveJumps); + + movement.Then().MoveToY(-10 * multiplier, 200, Easing.Out) + .Then().MoveToY(0, 200, Easing.In); + + scale.Then().ScaleTo(new Vector2(1, 1.05f), 200, Easing.Out) + .Then().ScaleTo(new Vector2(1, 0.95f), 200, Easing.In) + .Then().ScaleTo(Vector2.One, 800, Easing.OutElastic); + + // only play jump sample if panel is visible + if (Alpha > 0) + playJumpSample(isConsecutive); + + break; + } + + break; + } + } + + private void onBeatmapAvailabilityChanged(MultiplayerRoomUser user, BeatmapAvailability availability) => Scheduler.Add(() => + { + if (availability.State == DownloadState.Downloading) + downloadProgressBar.FadeIn(200, Easing.OutPow10); + else + downloadProgressBar.FadeOut(200, Easing.OutPow10); + + downloadProgressBar.ResizeWidthTo(availability.DownloadProgress ?? 0, 200, Easing.OutPow10); + }); + + private void playJumpSample(bool rejumping) + { + bool isLocalUser = User.OnlineID == client.LocalUser?.UserID; + + if (isLocalUser) + playInteractionSample(rejumping ? InteractionSampleType.PlayerReJump : InteractionSampleType.PlayerJump); + else + playInteractionSample(InteractionSampleType.OtherPlayerJump); + } + + private void playInteractionSample(InteractionSampleType sampleType) + { + bool enoughTimePassedSinceLastPlayback = lastSamplePlayback == null || Time.Current - lastSamplePlayback.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; + if (!enoughTimePassedSinceLastPlayback) + return; + + Sample? targetSample = interactionSamples[sampleType]; + SampleChannel? targetChannel = interactionSampleChannels.GetValueOrDefault(sampleType); + + targetChannel?.Stop(); + targetChannel = targetSample?.GetChannel(); + + if (targetChannel == null) + return; + + float horizontalPos = BoundingBox.Centre.X / Parent!.ToLocalSpace(Parent!.ScreenSpaceDrawQuad).Width; + // rescale balance from 0..1 to -1..1 + float balance = -1f + horizontalPos * 2f; + + targetChannel.Frequency.Value = samplePitch; + targetChannel.Balance.Value = balance * OsuGameBase.SFX_STEREO_STRENGTH; + targetChannel.Play(); + + interactionSampleChannels[sampleType] = targetChannel; + + lastSamplePlayback = Time.Current; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.MatchRoomStateChanged -= onRoomStateChanged; + client.MatchEvent -= onMatchEvent; + client.BeatmapAvailabilityChanged -= onBeatmapAvailabilityChanged; + } + } + + public MenuItem[] ContextMenuItems + { + get + { + List items = new List + { + new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, viewProfile) + }; + + if (User.Equals(api.LocalUser.Value)) + return items.ToArray(); + + items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, () => + { + channelManager?.OpenPrivateChannel(User); + chatOverlay?.Show(); + })); + + items.Add(!isUserBlocked() + ? new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Block(User))) + : new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Unblock(User)))); + + if (isUserOnline()) + { + items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => + { + if (isUserOnline()) + performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))); + })); + + if (canInviteUser()) + { + items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => + { + if (canInviteUser()) + multiplayerClient!.InvitePlayer(User.Id); + })); + } + } + + return items.ToArray(); + + bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null; + bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true; + bool isUserBlocked() => api.LocalUserState.Blocks.Any(b => b.TargetID == User.OnlineID); + } + } + } + + public enum PlayerPanelDisplayMode + { + AvatarOnly, + Horizontal, + Vertical + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs new file mode 100644 index 0000000000..4b97400ebe --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs @@ -0,0 +1,312 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + /// + /// A component which maintains the layout of the players in a matchmaking room. + /// Can be controlled to display the panels in a certain location and in multiple styles. + /// + public partial class PlayerPanelOverlay : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private Container panels = null!; + private PlayerPanelCellContainer gridLayout = null!; + private PlayerPanelCellContainer splitLayoutLeft = null!; + private PlayerPanelCellContainer splitLayoutRight = null!; + + private PanelDisplayStyle displayStyle; + private Drawable? displayArea; + private bool isAnimatingToDisplayArea; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + gridLayout = new PlayerPanelCellContainer + { + RelativeSizeAxes = Axes.Both, + Spacing = new Vector2(20), + }, + splitLayoutLeft = new PlayerPanelCellContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + }, + splitLayoutRight = new PlayerPanelCellContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + }, + panels = new Container + { + RelativeSizeAxes = Axes.Both + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Set position/size so we don't initially animate. + Position = getFinalPosition(); + Size = getFinalSize(); + + client.MatchRoomStateChanged += onRoomStateChanged; + client.UserJoined += onUserJoined; + client.UserLeft += onUserLeft; + + if (client.Room != null) + { + onRoomStateChanged(client.Room.MatchState); + foreach (var user in client.Room.Users) + onUserJoined(user); + } + + updateDisplay(); + } + + public PanelDisplayStyle DisplayStyle + { + set + { + displayStyle = value; + if (IsLoaded) + updateDisplay(); + } + } + + public Drawable? DisplayArea + { + set + { + displayArea = value; + isAnimatingToDisplayArea = true; + } + } + + private void onUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => + { + panels.Add(new PlayerPanel(user) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.8f) + }); + + updateDisplay(); + }); + + private void onUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => + { + panels.Single(p => p.RoomUser.Equals(user)).HasQuit = true; + updateDisplay(); + }); + + private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(updateDisplay); + + private void updateDisplay() + { + gridLayout.ReleasePanels(); + splitLayoutLeft.ReleasePanels(); + splitLayoutRight.ReleasePanels(); + + switch (displayStyle) + { + case PanelDisplayStyle.Grid: + foreach (var panel in panels) + { + panel.FadeTo(1, 200); + panel.DisplayMode = PlayerPanelDisplayMode.Vertical; + } + + gridLayout.AcquirePanels(panels.ToArray()); + break; + + case PanelDisplayStyle.Split: + foreach (var panel in panels) + { + panel.FadeTo(1, 200); + panel.DisplayMode = PlayerPanelDisplayMode.Horizontal; + } + + int leftCount = (int)Math.Ceiling(panels.Count / 2f); + + splitLayoutLeft.AcquirePanels(panels.Take(leftCount).ToArray()); + splitLayoutRight.AcquirePanels(panels.Skip(leftCount).ToArray()); + break; + + case PanelDisplayStyle.Hidden: + foreach (var panel in panels) + panel.FadeTo(0, 200); + return; + } + } + + protected override void Update() + { + base.Update(); + + var targetPos = getFinalPosition(); + var targetSize = getFinalSize(); + + double duration = isAnimatingToDisplayArea ? 60 : 0; + + if (Time.Elapsed > 0) + { + Position = new Vector2( + (float)Interpolation.DampContinuously(Position.X, targetPos.X, duration, Time.Elapsed), + (float)Interpolation.DampContinuously(Position.Y, targetPos.Y, duration, Time.Elapsed) + ); + + Size = new Vector2( + (float)Interpolation.DampContinuously(Size.X, targetSize.X, duration, Time.Elapsed), + (float)Interpolation.DampContinuously(Size.Y, targetSize.Y, duration, Time.Elapsed) + ); + } + + // If we don't track the animating state, the animation will also occur when resizing the window. + isAnimatingToDisplayArea &= !Precision.AlmostEquals(Size, targetSize, 0.5f); + } + + private Vector2 getFinalPosition() + => displayArea == null ? Vector2.Zero : Parent!.ToLocalSpace(displayArea.ScreenSpaceDrawQuad.TopLeft); + + private Vector2 getFinalSize() + => displayArea == null ? Parent!.DrawSize : Parent!.ToLocalSpace(displayArea.ScreenSpaceDrawQuad.BottomRight) - Parent!.ToLocalSpace(displayArea.ScreenSpaceDrawQuad.TopLeft); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.MatchRoomStateChanged -= onRoomStateChanged; + client.UserJoined -= onUserJoined; + client.UserLeft -= onUserLeft; + } + } + + private partial class PlayerPanelCellContainer : FillFlowContainer + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + public void AcquirePanels(PlayerPanel[] panels) + { + while (Count < panels.Length) + { + Add(new PlayerPanelCell + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + while (Count > panels.Length) + Remove(Children[^1], true); + + for (int i = 0; i < panels.Length; i++) + { + // We'll invalidate the layout position to represent the new placements and the re-flow will happen in UpdateAfterChildren(). + // But the cells expect their positions to be valid as they're updated, which won't be the case until the re-flow happens. + int i2 = i; + ScheduleAfterChildren(() => Children[i2].AcquirePanel(panels[i2])); + + if (client.Room?.MatchState is not MatchmakingRoomState matchmakingState) + continue; + + if (matchmakingState.Users.UserDictionary.TryGetValue(panels[i].User.Id, out MatchmakingUser? user) && user.Placement != null) + SetLayoutPosition(Children[i], user.Placement.Value); + else + SetLayoutPosition(Children[i], float.MaxValue); + } + } + + public void ReleasePanels() + { + // Matches the schedule in AcquirePanels. + ScheduleAfterChildren(() => + { + foreach (var panel in Children) + panel.ReleasePanel(); + }); + } + } + + private partial class PlayerPanelCell : Drawable + { + private PlayerPanel? panel; + private bool isAnimating; + + public void AcquirePanel(PlayerPanel panel) + { + this.panel = panel; + isAnimating = true; + } + + public void ReleasePanel() + { + panel = null; + } + + protected override void Update() + { + base.Update(); + + if (panel?.Parent == null) + return; + + Size = panel.Size * panel.Scale; + + var targetPos = getFinalPosition(); + + double duration = isAnimating ? 60 : 0; + + if (Time.Elapsed > 0) + { + panel.Position = new Vector2( + (float)Interpolation.DampContinuously(panel.Position.X, targetPos.X, duration, Time.Elapsed), + (float)Interpolation.DampContinuously(panel.Position.Y, targetPos.Y, duration, Time.Elapsed) + ); + } + + // If we don't track the animating state, the animation will also occur when resizing the window. + isAnimating &= !Precision.AlmostEquals(panel.Position, targetPos, 0.5f); + + Vector2 getFinalPosition() + => panel.Parent.ToLocalSpace(ScreenSpaceDrawQuad.Centre) - panel.AnchorPosition; + } + } + } + + public enum PanelDisplayStyle + { + Grid, + Split, + Hidden + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/PanelRoomAward.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/PanelRoomAward.cs new file mode 100644 index 0000000000..fb93d5e804 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/PanelRoomAward.cs @@ -0,0 +1,137 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results +{ + public partial class PanelRoomAward : OsuClickableContainer + { + private readonly string text; + private readonly string description; + private readonly int userId; + + private Box glossLayer = null!; + private Container scaleContainer = null!; + + public PanelRoomAward(string text, string description, int userId) + { + this.text = text; + this.description = description; + this.userId = userId; + + Height = 40; + RelativeSizeAxes = Axes.X; + + // Just make hover sounds work for now. + Action = () => { }; + } + + [BackgroundDependencyLoader] + private void load(UserLookupCache userLookupCache, OverlayColourProvider colourProvider) + { + // Should be cached by this point. + APIUser user = userLookupCache.GetUserAsync(userId).GetResultSafely()!; + + Child = scaleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(10), + Spacing = new Vector2(10), + Children = new Drawable[] + { + new MatchmakingAvatar(user) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.Style.Caption1, + Text = user.Username + }, + new OsuSpriteText + { + Font = OsuFont.Style.Caption2.With(weight: FontWeight.Bold), + Text = text + } + } + }, + } + }, + glossLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + Rotation = 30, + Scale = new Vector2(0.1f, 3), + Colour = ColourInfo.GradientHorizontal( + colourProvider.Background2.Opacity(0), + colourProvider.Background2), + Alpha = 0.1f, + Blending = BlendingParameters.Additive, + }, + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + scaleContainer.ScaleTo(1.15f, 2000, Easing.OutPow10); + glossLayer + .FadeTo(0.05f, 2000, Easing.OutPow10) + .MoveToX(-8, 2000, Easing.OutPow10); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + scaleContainer.ScaleTo(1f, 500, Easing.OutQuint); + glossLayer + .FadeTo(0.1f, 500, Easing.OutQuint) + .MoveToX(0, 500, Easing.OutQuint); + base.OnHoverLost(e); + } + + public override LocalisableString TooltipText => description; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/PanelUserStatistic.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/PanelUserStatistic.cs new file mode 100644 index 0000000000..c1b1be0b2b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/PanelUserStatistic.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.Globalization; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results +{ + public partial class PanelUserStatistic : CompositeDrawable + { + private readonly int position; + private readonly string text; + + public PanelUserStatistic(int position, string text) + { + this.position = position; + this.text = text; + + AutoSizeAxes = Axes.Both; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new Container + { + Width = 30, + Masking = true, + CornerRadius = 6, + CornerExponent = 10, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = SubScreenResults.ColourForPlacement(position), + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(weight: FontWeight.Bold), + Text = position.Ordinalize(CultureInfo.CurrentCulture), + Colour = colourProvider.Background4, + }, + } + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption2, + Text = text + } + } + }, + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs new file mode 100644 index 0000000000..2f3fb2debb --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs @@ -0,0 +1,382 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Globalization; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +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.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Overlays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results +{ + /// + /// Final room results, during + /// + public partial class SubScreenResults : MatchmakingSubScreen + { + private const float grid_spacing = 5; + + public override PanelDisplayStyle PlayersDisplayStyle => PanelDisplayStyle.Grid; + + public override Drawable PlayersDisplayArea { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private OsuSpriteText placementText = null!; + private FillFlowContainer userStatistics = null!; + private FillFlowContainer roomAwards = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + Padding = new MarginPadding(5), + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, grid_spacing), + new Dimension(), + }, + Content = new[] + { + new[] + { + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = 5, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(6), + Spacing = new Vector2(grid_spacing), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "How you played", + Font = OsuFont.Style.Heading2, + Margin = new MarginPadding { Vertical = 15 }, + }, + userStatistics = new FillFlowContainer + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(grid_spacing) + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Room Awards", + Font = OsuFont.Style.Heading2, + Margin = new MarginPadding { Vertical = 15 }, + }, + roomAwards = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(grid_spacing) + } + } + } + }, + }, + Empty(), + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, grid_spacing), + new Dimension(), + ], + Content = new Drawable[]?[] + { + [ + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(16), + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Your final placement", + Font = OsuFont.Style.Heading2.With(size: 36), + }, + placementText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Heading1.With(size: 72), + UseFullGlyphHeight = false + } + } + } + ], + null, + [ + PlayersDisplayArea, + ], + } + }, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchRoomStateChanged += onRoomStateChanged; + + onRoomStateChanged(client.Room?.MatchState); + } + + private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState || matchmakingState.Stage != MatchmakingStage.Ended) + return; + + populateUserStatistics(matchmakingState); + populateRoomStatistics(matchmakingState); + }); + + private void populateUserStatistics(MatchmakingRoomState state) + { + userStatistics.Clear(); + + var localUserState = state.Users.GetOrAdd(client.LocalUser!.UserID); + + if (localUserState.Rounds.Count == 0) + { + placementText.Text = "-"; + placementText.Colour = OsuColour.Gray(1f); + return; + } + + int? overallPlacement = localUserState.Placement; + + if (overallPlacement != null) + { + placementText.Text = overallPlacement.Value.Ordinalize(CultureInfo.CurrentCulture); + placementText.Colour = ColourForPlacement(overallPlacement.Value); + + int overallPoints = localUserState.Points; + addStatistic(overallPlacement.Value, $"Overall position ({overallPoints} points)"); + } + + var accuracyOrderedUsers = state.Users.Select(u => (user: u, avgAcc: u.Rounds.Select(r => r.Accuracy).DefaultIfEmpty(0).Average())) + .OrderByDescending(t => t.avgAcc) + .Select((t, i) => (info: t, index: i)) + .Single(t => t.info.user.UserId == client.LocalUser!.UserID); + int accuracyPlacement = accuracyOrderedUsers.index + 1; + addStatistic(accuracyPlacement, $"Overall accuracy ({accuracyOrderedUsers.info.avgAcc.FormatAccuracy()})"); + + var maxComboOrderedUsers = state.Users.Select(u => (user: u, maxCombo: u.Rounds.Select(r => r.MaxCombo).DefaultIfEmpty(0).Max())) + .OrderByDescending(t => t.maxCombo) + .Select((t, i) => (info: t, index: i)) + .Single(t => t.info.user.UserId == client.LocalUser!.UserID); + int maxComboPlacement = maxComboOrderedUsers.index + 1; + addStatistic(maxComboPlacement, $"Best max combo ({maxComboOrderedUsers.info.maxCombo}x)"); + + var bestPlacement = localUserState.Rounds.MinBy(r => r.Placement); + if (bestPlacement != null) + addStatistic(bestPlacement.Placement, $"Best round placement (round {bestPlacement.Round})"); + + void addStatistic(int position, string text) => userStatistics.Add(new PanelUserStatistic(position, text)); + } + + public static ColourInfo ColourForPlacement(int overallPlacement) + { + // for top 3 placements use special colours. + // don't for the rest. + + switch (overallPlacement) + { + case 1: + return OsuColour.ForRankingTier(RankingTier.Gold); + + case 2: + return OsuColour.ForRankingTier(RankingTier.Silver); + + case 3: + return OsuColour.ForRankingTier(RankingTier.Bronze); + + default: + return OsuColour.ForRankingTier(RankingTier.Iron); + } + } + + private void populateRoomStatistics(MatchmakingRoomState state) + { + roomAwards.Clear(); + + long maxScore = long.MinValue; + int maxScoreUserId = -1; + + double maxAccuracy = double.MinValue; + int maxAccuracyUserId = -1; + + int maxCombo = int.MinValue; + int maxComboUserId = -1; + + long maxBonusScore = 0; + int maxBonusScoreUserId = -1; + + long largestScoreDifference = long.MinValue; + int largestScoreDifferenceUserId = -1; + + long smallestScoreDifference = long.MaxValue; + int smallestScoreDifferenceUserId = -1; + + for (int round = 1; round <= state.CurrentRound; round++) + { + long roundHighestScore = long.MinValue; + int roundHighestScoreUserId = -1; + + long roundLowestScore = long.MaxValue; + + foreach (MatchmakingUser user in state.Users) + { + if (!user.Rounds.RoundsDictionary.TryGetValue(round, out MatchmakingRound? mmRound)) + continue; + + if (mmRound.TotalScore > maxScore) + { + maxScore = mmRound.TotalScore; + maxScoreUserId = user.UserId; + } + + if (mmRound.Accuracy > maxAccuracy) + { + maxAccuracy = mmRound.Accuracy; + maxAccuracyUserId = user.UserId; + } + + if (mmRound.MaxCombo > maxCombo) + { + maxCombo = mmRound.MaxCombo; + maxComboUserId = user.UserId; + } + + if (mmRound.TotalScore > roundHighestScore) + { + roundHighestScore = mmRound.TotalScore; + roundHighestScoreUserId = user.UserId; + } + + if (mmRound.TotalScore < roundLowestScore) + roundLowestScore = mmRound.TotalScore; + } + + long roundScoreDifference = roundHighestScore - roundLowestScore; + + if (roundScoreDifference > 0 && roundScoreDifference > largestScoreDifference) + { + largestScoreDifference = roundScoreDifference; + largestScoreDifferenceUserId = roundHighestScoreUserId; + } + + if (roundScoreDifference > 0 && roundScoreDifference < smallestScoreDifference) + { + smallestScoreDifference = roundScoreDifference; + smallestScoreDifferenceUserId = roundHighestScoreUserId; + } + } + + foreach (MatchmakingUser user in state.Users) + { + int userBonusScore = 0; + + foreach (MatchmakingRound round in user.Rounds) + { + userBonusScore += round.Statistics.TryGetValue(HitResult.LargeBonus, out int bonus) ? bonus * 5 : 0; + userBonusScore += round.Statistics.TryGetValue(HitResult.SmallBonus, out bonus) ? bonus : 0; + } + + if (userBonusScore > maxBonusScore) + { + maxBonusScore = userBonusScore; + maxBonusScoreUserId = user.UserId; + } + } + + if (maxScoreUserId > 0) + addAward(maxScoreUserId, "Score champ", "Highest score in a single round"); + + if (maxAccuracyUserId > 0) + addAward(maxAccuracyUserId, "Most accurate", "Highest accuracy in a single round"); + + if (maxComboUserId > 0) + addAward(maxComboUserId, "Top combo", "Highest combo in a single round"); + + if (maxBonusScoreUserId > 0) + addAward(maxBonusScoreUserId, "Biggest bonus", "Biggest bonus score across all rounds"); + + if (smallestScoreDifferenceUserId > 0) + addAward(smallestScoreDifferenceUserId, "Most clutch", "Smallest winning score difference in a single round"); + + if (largestScoreDifferenceUserId > 0) + addAward(largestScoreDifferenceUserId, "Best finish", "Largest score difference in a single round"); + + void addAward(int userId, string text, string description) => roomAwards.Add(new PanelRoomAward(text, description, userId)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchRoomStateChanged -= onRoomStateChanged; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundResults/SubScreenRoundResults.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundResults/SubScreenRoundResults.cs new file mode 100644 index 0000000000..580d157a8b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundResults/SubScreenRoundResults.cs @@ -0,0 +1,214 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults +{ + /// + /// Per-round results, during + /// + public partial class SubScreenRoundResults : MatchmakingSubScreen + { + private const int panel_spacing = 5; + + public override PanelDisplayStyle PlayersDisplayStyle => PanelDisplayStyle.Hidden; + public override Drawable? PlayersDisplayArea => null; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private AutoScrollContainer scrollContainer = null!; + private LoadingSpinner loadingSpinner = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + scrollContainer = new AutoScrollContainer + { + RelativeSizeAxes = Axes.Both + }, + loadingSpinner = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + loadingSpinner.Show(); + + queryScores().FireAndForget(); + } + + private async Task queryScores() + { + try + { + if (client.Room == null) + return; + + Task beatmapTask = beatmapLookupCache.GetBeatmapAsync(client.Room.CurrentPlaylistItem.BeatmapID); + TaskCompletionSource> scoreTask = new TaskCompletionSource>(); + + var request = new IndexPlaylistScoresRequest(client.Room.RoomID, client.Room.Settings.PlaylistItemId); + request.Success += req => scoreTask.SetResult(req.Scores); + request.Failure += e => scoreTask.SetException(e); + api.Queue(request); + + await Task.WhenAll(beatmapTask, scoreTask.Task).ConfigureAwait(false); + + APIBeatmap? apiBeatmap = beatmapTask.GetResultSafely(); + List apiScores = scoreTask.Task.GetResultSafely(); + + if (apiBeatmap == null) + return; + + // Reference: PlaylistItemResultsScreen + setScores(apiScores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, new BeatmapInfo + { + Difficulty = new BeatmapDifficulty(apiBeatmap.Difficulty), + Metadata = + { + Artist = apiBeatmap.Metadata.Artist, + Title = apiBeatmap.Metadata.Title, + Author = new RealmUser + { + Username = apiBeatmap.Metadata.Author.Username, + OnlineID = apiBeatmap.Metadata.Author.OnlineID, + } + }, + DifficultyName = apiBeatmap.DifficultyName, + StarRating = apiBeatmap.StarRating, + Length = apiBeatmap.Length, + BPM = apiBeatmap.BPM + })).ToArray()); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load scores for playlist item."); + throw; + } + finally + { + Scheduler.Add(() => loadingSpinner.Hide()); + } + } + + private void setScores(ScoreInfo[] scores) => Scheduler.Add(() => + { + Container panels; + + scrollContainer.Child = panels = new Container + { + RelativeSizeAxes = Axes.Y, + Width = scores.Length * (ScorePanel.CONTRACTED_WIDTH + panel_spacing), + ChildrenEnumerable = scores.Select(s => new RoundResultsScorePanel(s) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }) + }; + + for (int i = 0; i < panels.Count; i++) + { + panels[i].MoveToX(panels.DrawWidth * 2) + .Delay(i * 100) + .MoveToX((ScorePanel.CONTRACTED_WIDTH + panel_spacing) * i, 500, Easing.OutQuint); + } + }); + + private partial class RoundResultsScorePanel : CompositeDrawable + { + public RoundResultsScorePanel(ScoreInfo score) + { + AutoSizeAxes = Axes.Both; + InternalChild = new InstantSizingScorePanel(score); + } + + public override bool PropagateNonPositionalInputSubTree => false; + public override bool PropagatePositionalInputSubTree => false; + + private partial class InstantSizingScorePanel : ScorePanel + { + public InstantSizingScorePanel(ScoreInfo score, bool isNewLocalScore = false) + : base(score, isNewLocalScore) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + FinishTransforms(true); + } + } + } + + private partial class AutoScrollContainer : UserTrackingScrollContainer + { + private const float initial_offset = -0.5f; + private const double scroll_duration = 20000; + + private double? scrollStartTime; + + public AutoScrollContainer() + : base(Direction.Horizontal) + { + } + + protected override void Update() + { + base.Update(); + + if (!UserScrolling && Children.Count > 0) + { + scrollStartTime ??= Time.Current; + + double scrollOffset = (Time.Current - scrollStartTime.Value) / scroll_duration; + + if (scrollOffset < 1) + ScrollTo(DrawWidth * (initial_offset + scrollOffset), false); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundWarmup/SubScreenRoundWarmup.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundWarmup/SubScreenRoundWarmup.cs new file mode 100644 index 0000000000..e389cbabfa --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/RoundWarmup/SubScreenRoundWarmup.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundWarmup +{ + /// + /// Shown during + /// + public partial class SubScreenRoundWarmup : MatchmakingSubScreen + { + public override PanelDisplayStyle PlayersDisplayStyle => PanelDisplayStyle.Grid; + public override Drawable PlayersDisplayArea => this; + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + this.MoveToX(0); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.HistoryFooterButton.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.HistoryFooterButton.cs new file mode 100644 index 0000000000..f46c0611c5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.HistoryFooterButton.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.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Footer; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class ScreenMatchmaking + { + private partial class HistoryFooterButton : ScreenFooterButton + { + [Resolved] + private OsuGame? game { get; set; } + + private readonly MultiplayerRoom room; + + public HistoryFooterButton(MultiplayerRoom room) + { + this.room = room; + + Action = openRoomHistory; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Text = "History"; + Icon = FontAwesome.Solid.Globe; + AccentColour = colours.Lime1; + } + + private void openRoomHistory() + => game?.OpenUrlExternally($@"/multiplayer/rooms/{room.RoomID}/events"); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.ScreenStack.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.ScreenStack.cs new file mode 100644 index 0000000000..279dd98a5e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.ScreenStack.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.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundWarmup; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class ScreenMatchmaking + { + public partial class ScreenStack : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private Framework.Screens.ScreenStack screenStack = null!; + private PlayerPanelOverlay playersList = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(6) + { + Bottom = StageDisplay.HEIGHT + 6, + }, + Children = new Drawable[] + { + screenStack = new Framework.Screens.ScreenStack(), + } + }, + playersList = new PlayerPanelOverlay + { + DisplayArea = this + }, + new StageDisplay + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + screenStack.ScreenPushed += onScreenPushed; + screenStack.ScreenExited += onScreenExited; + + screenStack.Push(new SubScreenRoundWarmup()); + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + onMatchRoomStateChanged(client.Room!.MatchState); + } + + private void onScreenPushed(IScreen lastScreen, IScreen newScreen) + { + if (newScreen is not MatchmakingSubScreen matchmakingSubScreen) + return; + + playersList.DisplayStyle = matchmakingSubScreen.PlayersDisplayStyle; + playersList.DisplayArea = matchmakingSubScreen.PlayersDisplayArea; + } + + private void onScreenExited(IScreen lastScreen, IScreen newScreen) + { + if (newScreen is not MatchmakingSubScreen matchmakingSubScreen) + return; + + playersList.DisplayStyle = matchmakingSubScreen.PlayersDisplayStyle; + playersList.DisplayArea = matchmakingSubScreen.PlayersDisplayArea; + } + + private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + switch (matchmakingState.Stage) + { + case MatchmakingStage.WaitingForClientsJoin: + case MatchmakingStage.RoundWarmupTime: + while (screenStack.CurrentScreen is not SubScreenRoundWarmup) + screenStack.Exit(); + break; + + case MatchmakingStage.UserBeatmapSelect: + screenStack.Push(new SubScreenBeatmapSelect()); + break; + + case MatchmakingStage.ServerBeatmapFinalised: + Debug.Assert(screenStack.CurrentScreen is SubScreenBeatmapSelect); + ((SubScreenBeatmapSelect)screenStack.CurrentScreen).RollFinalBeatmap(matchmakingState.CandidateItems, matchmakingState.CandidateItem); + break; + + case MatchmakingStage.ResultsDisplaying: + screenStack.Push(new SubScreenRoundResults()); + break; + + case MatchmakingStage.Ended: + screenStack.Push(new SubScreenResults()); + break; + } + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs new file mode 100644 index 0000000000..972f0b4adb --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs @@ -0,0 +1,490 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.Cursor; +using osu.Game.Online; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking.Events; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osu.Game.Screens.Footer; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Gameplay; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Users; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + /// + /// The main matchmaking screen which houses a custom through the life cycle of a single session. + /// + public partial class ScreenMatchmaking : OsuScreen, IPreviewTrackOwner, IHandlePresentBeatmap + { + /// + /// Padding between rows of the content. + /// + private const float row_padding = 10; + + private static readonly Vector2 chat_size = new Vector2(550, 130); + + public override bool? ApplyModTrackAdjustments => true; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool ShowFooter => true; + + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IDialogOverlay dialogOverlay { get; set; } = null!; + + [Resolved] + private AudioManager audio { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private MusicController music { get; set; } = null!; + + private readonly MultiplayerRoom room; + private readonly MatchmakingChatDisplay chat; + + private Sample? sampleStart; + private CancellationTokenSource? downloadCheckCancellation; + private int? lastDownloadCheckedBeatmapId; + + public ScreenMatchmaking(MultiplayerRoom room) + { + this.room = room; + + Activity.Value = new UserActivity.InLobby(room); + Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + + chat = new MatchmakingChatDisplay(new Room(room)) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Size = chat_size, + Margin = new MarginPadding + { + Right = WaveOverlayContainer.WIDTH_PADDING - HORIZONTAL_OVERFLOW_PADDING, + Bottom = row_padding + }, + Alpha = 0 + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Top = row_padding, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, row_padding), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new Drawable[]?[] + { + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + new ScreenStack(), + } + } + ], + null, + [ + new Container + { + Name = "Chat Area Space", + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Size = new Vector2(550, 130), + Margin = new MarginPadding { Bottom = row_padding } + } + ] + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + client.UserStateChanged += onUserStateChanged; + client.SettingsChanged += onSettingsChanged; + client.LoadRequested += onLoadRequested; + + beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); + + Footer?.Add(new ChatContainer(chat)); + } + + private void onRoomUpdated() + { + if (this.IsCurrentScreen() && client.Room == null) + { + Logger.Log($"{this} exiting due to loss of room or connection"); + this.Exit(); + } + } + + private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state) + { + if (user.Equals(client.LocalUser) && state == MultiplayerUserState.Idle) + this.MakeCurrent(); + } + + private void onSettingsChanged(MultiplayerRoomSettings _) => Scheduler.Add(() => + { + checkForAutomaticDownload(); + updateGameplayState(); + }); + + private void onBeatmapAvailabilityChanged(ValueChangedEvent e) => Scheduler.Add(() => + { + if (client.Room == null || client.LocalUser == null) + return; + + client.ChangeBeatmapAvailability(e.NewValue).FireAndForget(); + + switch (e.NewValue.State) + { + case DownloadState.NotDownloaded: + case DownloadState.LocallyAvailable: + updateGameplayState(); + break; + } + }); + + private void updateGameplayState() + { + MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem; + + if (item.Expired) + return; + + RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = ruleset.CreateInstance(); + + // Update global gameplay state to correspond to the new selection. + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + + if (localBeatmap != null) + { + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = ruleset; + Mods.Value = item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + + // Notify the server that the beatmap has been set and that we are ready to start gameplay. + if (client.LocalUser!.State == MultiplayerUserState.Idle) + client.ChangeState(MultiplayerUserState.Ready).FireAndForget(); + } + else + { + // Notify the server that we don't have the beatmap. + if (client.LocalUser!.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + } + + client.ChangeBeatmapAvailability(beatmapAvailabilityTracker.Availability.Value).FireAndForget(); + } + + private void onLoadRequested() => Scheduler.Add(() => + { + updateGameplayState(); + + if (Beatmap.IsDefault) + { + Logger.Log("Aborting gameplay start - beatmap not downloaded."); + return; + } + + sampleStart?.Play(); + + this.Push(new MultiplayerPlayerLoader(() => new ScreenGameplay(new Room(room), new PlaylistItem(client.Room!.CurrentPlaylistItem), room.Users.ToArray()))); + }); + + private void checkForAutomaticDownload() + { + if (client.Room == null) + return; + + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + + // This method is called every time anything changes in the room. + // This could result in download requests firing far too often, when we only expect them to fire once per beatmap. + // + // Without this check, we would see especially egregious behaviour when a user has hit the download rate limit. + if (lastDownloadCheckedBeatmapId == item.BeatmapID) + return; + + lastDownloadCheckedBeatmapId = item.BeatmapID; + + downloadCheckCancellation?.Cancel(); + + if (beatmapManager.IsAvailableLocally(new APIBeatmap { OnlineID = item.BeatmapID })) + return; + + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. + // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. + beatmapLookupCache + .GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token) + .ContinueWith(resolved => Schedule(() => + { + APIBeatmapSet? beatmapSet = resolved.GetResultSafely()?.BeatmapSet; + + if (beatmapSet == null) + return; + + beatmapDownloader.Download(beatmapSet, config.Get(OsuSetting.PreferNoVideo)); + })); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Space: + if (e.Repeat) + return true; + + client.SendMatchRequest(new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).FireAndForget(); + return true; + } + + return false; + } + + public override IReadOnlyList CreateFooterButtons() => + [ + new HistoryFooterButton(room) + ]; + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + chat.Appear(); + beginHandlingTrack(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + chat.Disappear(); + endHandlingTrack(); + + base.OnSuspending(e); + } + + private bool exitConfirmed; + + public override bool OnExiting(ScreenExitEvent e) + { + if (exitConfirmed) + { + if (base.OnExiting(e)) + { + exitConfirmed = false; + return true; + } + + chat.Disappear().Expire(); + endHandlingTrack(); + + client.LeaveRoom().FireAndForget(); + return false; + } + + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else + { + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + { + exitConfirmed = true; + if (this.IsCurrentScreen()) + this.Exit(); + })); + } + + return true; + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + chat.Appear(); + beginHandlingTrack(); + + if (e.Last is not MultiplayerPlayerLoader playerLoader) + return; + + if (!playerLoader.GameplayPassed) + { + client.AbortGameplay().FireAndForget(); + return; + } + + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + } + + /// + /// Handles changes in the track to keep it looping while active. + /// + private void beginHandlingTrack() + { + Beatmap.BindValueChanged(applyLoopingToTrack, true); + } + + /// + /// Stops looping the current track and stops handling further changes to the track. + /// + private void endHandlingTrack() + { + Beatmap.ValueChanged -= applyLoopingToTrack; + Beatmap.Value.Track.Looping = false; + + previewTrackManager.StopAnyPlaying(this); + } + + /// + /// Invoked on changes to the beatmap to loop the track. See: . + /// + /// The beatmap change event. + private void applyLoopingToTrack(ValueChangedEvent beatmap) + { + if (!this.IsCurrentScreen()) + return; + + beatmap.NewValue.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + // Do nothing to prevent the user from potentially being kicked out + // of gameplay due to the screen performer's internal processes. + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.RoomUpdated -= onRoomUpdated; + client.UserStateChanged -= onUserStateChanged; + client.SettingsChanged -= onSettingsChanged; + client.LoadRequested -= onLoadRequested; + } + } + + // Contains the chat display and a context menu container for it. Shared lifetime with the chat display (expires along with it). + private partial class ChatContainer : CompositeDrawable + { + public override double LifetimeStart => chat.LifetimeStart; + public override double LifetimeEnd => chat.LifetimeEnd; + + private readonly MatchmakingChatDisplay chat; + + public ChatContainer(MatchmakingChatDisplay chat) + { + this.chat = chat; + + Anchor = Anchor.BottomRight; + Origin = Anchor.BottomRight; + + // This component is added to the screen footer which is only about 50px high. + // Therefore, it's given a large absolute size to give the context menu enough space to display correctly. + Size = new Vector2(chat_size.X); + + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = chat + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StageSegment.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StageSegment.cs new file mode 100644 index 0000000000..7e3b7d4468 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StageSegment.cs @@ -0,0 +1,234 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class StageDisplay + { + internal partial class StageSegment : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + public readonly int? Round; + + private readonly MatchmakingStage stage; + + private readonly LocalisableString displayText; + private Drawable progressBar = null!; + + private DateTimeOffset countdownStartTime; + private DateTimeOffset countdownEndTime; + private SpriteIcon arrow = null!; + + private Sample? segmentStartedSample; + + private Container mainContent = null!; + + public bool Active { get; private set; } + + public float Progress => progressBar.Width; + + public StageSegment(int? round, MatchmakingStage stage, LocalisableString displayText) + { + Round = round; + + this.stage = stage; + this.displayText = displayText; + + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio, OverlayColourProvider colourProvider) + { + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + arrow = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Alpha = 0.5f, + Size = new Vector2(16), + Icon = FontAwesome.Solid.ArrowRight, + Margin = new MarginPadding { Horizontal = 10 } + }, + mainContent = new Container + { + Masking = true, + CornerRadius = 5, + CornerExponent = 10, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = + ColourInfo.GradientVertical( + colourProvider.Dark2, + colourProvider.Dark1 + ), + }, + progressBar = new Box + { + Blending = BlendingParameters.Additive, + EdgeSmoothness = new Vector2(1), + RelativeSizeAxes = Axes.Both, + Width = 0, + Colour = colourProvider.Dark3, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = displayText, + Padding = new MarginPadding(10) + } + } + } + } + }; + + Alpha = 0.5f; + segmentStartedSample = audio.Samples.Get(@"Multiplayer/Matchmaking/stage-segment"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + client.CountdownStarted += onCountdownStarted; + client.CountdownStopped += onCountdownStopped; + + if (client.Room != null) + { + onMatchRoomStateChanged(client.Room.MatchState); + foreach (var countdown in client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + } + + protected override void Update() + { + base.Update(); + + if (!Active) + return; + + TimeSpan total = countdownEndTime - countdownStartTime; + TimeSpan elapsed = DateTimeOffset.Now - countdownStartTime; + + if (total.TotalMilliseconds <= 0) + { + progressBar.Width = 0; + return; + } + + progressBar.Width = (float)Math.Clamp(elapsed.TotalMilliseconds / total.TotalMilliseconds, 0, 1); + } + + private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + bool wasActive = Active; + + Active = false; + + if (state is not MatchmakingRoomState roomState) + return; + + if (Round != null && roomState.CurrentRound != Round) + return; + + Active = stage == roomState.Stage; + + if (wasActive) + progressBar.Width = 1; + + mainContent.ScaleTo(Active ? 1.3f : 1, 500, Easing.OutQuint); + + bool isPreparing = + (stage == MatchmakingStage.RoundWarmupTime && roomState.Stage == MatchmakingStage.WaitingForClientsJoin) || + (stage == MatchmakingStage.GameplayWarmupTime && roomState.Stage == MatchmakingStage.WaitingForClientsBeatmapDownload) || + (stage == MatchmakingStage.ResultsDisplaying && roomState.Stage == MatchmakingStage.Gameplay); + + if (isPreparing) + { + arrow.FadeTo(1, 500) + .Then() + .FadeTo(0.5f, 500) + .Loop(); + } + }); + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (!Active) + return; + + if (countdown is not MatchmakingStageCountdown) + return; + + countdownStartTime = DateTimeOffset.Now; + countdownEndTime = countdownStartTime + countdown.TimeRemaining; + arrow.FadeIn(500, Easing.OutQuint); + + this.FadeIn(200); + + segmentStartedSample?.Play(); + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (!Active) + return; + + if (countdown is not MatchmakingStageCountdown) + return; + + countdownEndTime = DateTimeOffset.Now; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + client.CountdownStarted -= onCountdownStarted; + client.CountdownStopped -= onCountdownStopped; + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StatusText.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StatusText.cs new file mode 100644 index 0000000000..33692ffe03 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.StatusText.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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.ObjectExtensions; +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.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class StageDisplay + { + public partial class StatusText : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private OsuSpriteText text = null!; + + private Sample? textChangedSample; + private double? lastSamplePlayback; + + public StatusText() + { + AutoSizeAxes = Axes.X; + Height = 16; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + InternalChild = text = new OsuSpriteText + { + Alpha = 0, + Height = 16, + Font = OsuFont.Style.Caption1, + AlwaysPresent = true, + }; + + textChangedSample = audio.Samples.Get(@"Multiplayer/Matchmaking/stage-message"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + onMatchRoomStateChanged(client.Room!.MatchState); + } + + private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + text.Text = getTextForStatus(matchmakingState.Stage); + + if (text.Text == string.Empty || (lastSamplePlayback != null && Time.Current - lastSamplePlayback < OsuGameBase.SAMPLE_DEBOUNCE_TIME)) + return; + + if (matchmakingState.Stage is MatchmakingStage.WaitingForClientsJoin or MatchmakingStage.WaitingForClientsBeatmapDownload) + { + textChangedSample?.Play(); + lastSamplePlayback = Time.Current; + } + + LocalisableString textForStatus = getTextForStatus(matchmakingState.Stage); + + if (string.IsNullOrEmpty(textForStatus.ToString())) + { + text.FadeOut(); + return; + } + + text.RotateTo(2f) + .RotateTo(0, 500, Easing.OutQuint); + + text.FadeInFromZero(500, Easing.OutQuint); + + using (text.BeginDelayedSequence(500)) + { + text + .FadeTo(0.6f, 400, Easing.In) + .Then() + .FadeTo(1, 400, Easing.Out) + .Loop(); + } + + text.ScaleTo(0.3f) + .ScaleTo(1, 500, Easing.OutQuint); + + text.Text = textForStatus; + }); + + private LocalisableString getTextForStatus(MatchmakingStage status) + { + switch (status) + { + case MatchmakingStage.WaitingForClientsJoin: + return "Players are joining the match..."; + + case MatchmakingStage.WaitingForClientsBeatmapDownload: + return "Players are downloading the beatmap..."; + + case MatchmakingStage.Gameplay: + return "Game is in progress..."; + + case MatchmakingStage.Ended: + return "Thanks for playing! The match will close shortly."; + + default: + return string.Empty; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.TimerText.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.TimerText.cs new file mode 100644 index 0000000000..e2af3ef945 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.TimerText.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + public partial class StageDisplay + { + public partial class TimerText : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private OsuSpriteText text = null!; + + private DateTimeOffset countdownEndTime; + + public TimerText() + { + AutoSizeAxes = Axes.X; + Height = 18; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = text = new OsuSpriteText + { + Height = 18, + Spacing = new Vector2(-1, 0), + Font = OsuFont.Style.Heading2.With(fixedWidth: true), + AlwaysPresent = true, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.CountdownStarted += onCountdownStarted; + client.CountdownStopped += onCountdownStopped; + + if (client.Room != null) + { + foreach (var countdown in client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + } + + protected override void Update() + { + base.Update(); + + TimeSpan remaining = countdownEndTime - DateTimeOffset.Now; + + text.Alpha = remaining.TotalSeconds > 0 ? 1f : 0.2f; + + if (remaining.TotalSeconds > 10) + text.Font = text.Font.With(weight: FontWeight.SemiBold); + else + text.Font = text.Font.With(weight: FontWeight.Bold); + + int minutes = (int)Math.Max(0, remaining.TotalMinutes); + int seconds = Math.Max(0, remaining.Seconds); + int ms = Math.Max(0, remaining.Milliseconds); + + text.Text = $"{minutes:00}:{seconds:00}.{ms:000}"; + } + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is MatchmakingStageCountdown) + countdownEndTime = DateTimeOffset.Now + countdown.TimeRemaining; + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not MatchmakingStageCountdown) + return; + + countdownEndTime = DateTimeOffset.Now; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.CountdownStarted -= onCountdownStarted; + client.CountdownStopped -= onCountdownStopped; + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.cs new file mode 100644 index 0000000000..b45e8054a0 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/StageDisplay.cs @@ -0,0 +1,298 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match +{ + /// + /// A "global" footer staple element in matchmaking which shows the current progression of the room, from start to finish. + /// + public partial class StageDisplay : CompositeDrawable + { + public const float HEIGHT = 96; + + // TODO: get this from somewhere? + private const int round_count = 5; + + private OsuScrollContainer scroll = null!; + private FillFlowContainer flow = null!; + + private CurrentRoundDisplay roundDisplay = null!; + + public StageDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Dark6, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = HEIGHT, + Children = new Drawable[] + { + scroll = new StageScrollContainer + { + ScrollbarVisible = false, + ClampExtension = 0, + RelativeSizeAxes = Axes.X, + Height = HEIGHT, + Child = flow = new FillFlowContainer + { + Padding = new MarginPadding { Horizontal = 2000 }, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + }, + }, + new TimerText + { + Y = -38, + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }, + new StatusText + { + Y = 32, + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }, + new Box + { + Colour = ColourInfo.GradientHorizontal( + colourProvider.Dark4, + colourProvider.Dark5.Opacity(0) + ), + RelativeSizeAxes = Axes.Y, + Width = 240, + }, + roundDisplay = new CurrentRoundDisplay + { + X = 12, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + }; + + flow.Add(new StageSegment(null, MatchmakingStage.WaitingForClientsJoin, "Waiting for other users")); + + for (int i = 1; i <= round_count; i++) + { + flow.Add(new StageSegment(i, MatchmakingStage.RoundWarmupTime, "Next Round")); + flow.Add(new StageSegment(i, MatchmakingStage.UserBeatmapSelect, "Beatmap Selection")); + flow.Add(new StageSegment(i, MatchmakingStage.GameplayWarmupTime, "Get Ready")); + flow.Add(new StageSegment(i, MatchmakingStage.ResultsDisplaying, "Results")); + } + + flow.Add(new StageSegment(round_count, MatchmakingStage.Ended, "Match End")); + } + + protected override void Update() + { + base.Update(); + var bubble = flow.OfType().FirstOrDefault(b => b.Active); + + if (bubble != null) + { + scroll.ScrollTo(flow.Padding.Left + bubble.X + bubble.Progress * bubble.DrawWidth - scroll.DrawWidth / 2); + roundDisplay.Round = bubble.Round; + } + } + + private partial class StageScrollContainer : OsuScrollContainer + { + public override bool HandlePositionalInput => false; + public override bool HandleNonPositionalInput => false; + + public StageScrollContainer() + : base(Direction.Horizontal) + { + } + } + + private partial class CurrentRoundDisplay : CompositeDrawable + { + private OsuSpriteText text = null!; + + private Circle innerCircle = null!; + private CircularProgress progress = null!; + + private Sample? swishSample; + private Sample? swooshSample; + private Sample? roundUpSample; + private SampleChannel? swishChannel; + private SampleChannel? swooshChannel; + private SampleChannel? roundUpChannel; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours, AudioManager audio) + { + Size = new Vector2(76); + + InternalChildren = new Drawable[] + { + new Circle + { + Colour = ColourInfo.GradientVertical( + colours.Dark2, + colours.Dark4 + ), + RelativeSizeAxes = Axes.Both, + }, + progress = new CircularProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = ColourInfo.GradientVertical( + colours.Light1, + colours.Dark2 + ), + InnerRadius = 0.1f, + RelativeSizeAxes = Axes.Both, + }, + innerCircle = new Circle + { + Alpha = 0.2f, + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = ColourInfo.GradientVertical( + colours.Dark1, + colours.Dark2 + ), + Scale = new Vector2(0.9f), + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Y = 10, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Caption2, + Text = "Round", + }, + text = new OsuSpriteText + { + Font = OsuFont.Style.Heading1, + Position = new Vector2(-8, -3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "1" + }, + new OsuSpriteText + { + Font = OsuFont.Style.Heading2, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 4, + Text = "/" + }, + new OsuSpriteText + { + Font = OsuFont.Style.Heading1, + Position = new Vector2(10, 11), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = $"{round_count}" + }, + }; + + swishSample = audio.Samples.Get(@"UI/overlay-pop-in"); + swooshSample = audio.Samples.Get(@"UI/overlay-big-pop-out"); + roundUpSample = audio.Samples.Get(@"Multiplayer/Matchmaking/round-up"); + } + + private int round; + + public int? Round + { + set + { + value ??= 1; + + if (round == value) + return; + + round = value.Value; + + this.ScaleTo(6, 1000, Easing.OutPow10) + .MoveToY(-300, 1000, Easing.OutPow10) + .Then() + .MoveToY(0, 500, Easing.InQuart) + .ScaleTo(1, 500, Easing.InQuart); + + swishChannel = swishSample?.GetChannel(); + + if (swishChannel != null) + { + swishChannel.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH; + swishChannel?.Play(); + } + + Scheduler.AddDelayed(() => + { + swooshChannel = swooshSample?.GetChannel(); + + if (swooshChannel == null) return; + + swooshChannel.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH; + swooshChannel?.Play(); + }, 1250); + + Scheduler.AddDelayed(() => + { + progress.ProgressTo((float)round / round_count, 500, Easing.InOutQuart); + + Scheduler.AddDelayed(() => + { + roundUpChannel = roundUpSample?.GetChannel(); + + if (roundUpChannel != null) + { + roundUpChannel.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH; + roundUpChannel.Frequency.Value = 1f + round * 0.05f; + roundUpChannel?.Play(); + } + + innerCircle + .FadeTo(1, 250, Easing.OutQuint) + .Then() + .FadeTo(0.2f, 5000, Easing.OutQuint); + + text.Text = $"{round}"; + }, 150); + }, 250); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.cs new file mode 100644 index 0000000000..33ed21f3db --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/CloudVisualisation.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; +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.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osu.Game.Screens.Ranking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue +{ + /// + /// A visualisation at the top level of matchmaking which shows the overall system status. + /// This is intended to be something which users can watch while idle, for fun or otherwise. + /// + public partial class CloudVisualisation : CompositeDrawable + { + private APIUser[] users = []; + private Container usersContainer = null!; + + private readonly Bindable lastSamplePlayback = new Bindable(); + + public APIUser[] Users + { + get => users; + set + { + users = value; + + foreach (var u in usersContainer) + u.Delay(RNG.Next(0, 1000)).FadeOut(500).Expire(); + + LoadComponentsAsync(users.Select(u => new MovingAvatar(u, lastSamplePlayback)), avatars => + { + if (usersContainer.Count == 0) + { + usersContainer.ScaleTo(0) + .ScaleTo(1, 5000, Easing.OutPow10); + } + + usersContainer.AddRange(avatars); + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + usersContainer = new AspectContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + }, + }; + } + + public partial class MovingAvatar : MatchmakingAvatar + { + private float angle; + private float angularSpeed; + + private float targetSpeed; + private float targetScale; + private float targetAlpha; + + private readonly Bindable lastSamplePlayback = new Bindable(); + + private const int num_appear_samples = 6; + private Sample? playerAppearSample; + + public MovingAvatar(APIUser apiUser, Bindable lastSamplePlayback) + : base(apiUser) + { + RelativePositionAxes = Axes.Both; + Scale = new Vector2(2); + + Origin = Anchor.Centre; + this.lastSamplePlayback.BindTo(lastSamplePlayback); + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + playerAppearSample = audio.Samples.Get($@"Multiplayer/Matchmaking/Cloud/appear-{RNG.Next(0, num_appear_samples)}"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateParams(); + + angle = RNG.NextSingle(0f, MathF.Tau); + + angularSpeed = targetSpeed; + Scale = new Vector2(targetScale); + + Hide(); + int appearDelay = RNG.Next(0, 1000); + this.Delay(appearDelay).FadeTo(targetAlpha, 2000, Easing.OutQuint); + Scheduler.AddDelayed(playAppearSample, appearDelay); + } + + private void updateParams() + { + targetSpeed = RNG.NextSingle(0.05f, 0.5f); + targetScale = RNG.NextSingle(0.2f, 3f); + targetAlpha = RNG.NextSingle(0.5f, 1f); + + Scheduler.AddDelayed(updateParams, RNG.Next(500, 5000)); + } + + private void playAppearSample() + { + bool enoughTimeElapsed = !lastSamplePlayback.Value.HasValue || Time.Current - lastSamplePlayback.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; + if (!enoughTimeElapsed) return; + + var chan = playerAppearSample?.GetChannel(); + if (chan == null) return; + + chan.Frequency.Value = 0.5f + RNG.NextDouble(1.5f); + chan.Balance.Value = MathF.Cos(angle) * OsuGameBase.SFX_STEREO_STRENGTH; + chan.Play(); + + lastSamplePlayback.Value = Time.Current; + } + + protected override void Update() + { + base.Update(); + + float elapsed = (float)Math.Min(20, Time.Elapsed) / 1000; + + Scale = new Vector2((float)Interpolation.Lerp(Scale.X, targetScale, elapsed / 100)); + Alpha = (float)Interpolation.Lerp(Alpha, targetAlpha, elapsed / 100); + angularSpeed = (float)Interpolation.Lerp(angularSpeed, targetSpeed, elapsed / 100); + + angle += angularSpeed * elapsed * 0.5f; + + Position = new Vector2(0.5f) + + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * angularSpeed; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs new file mode 100644 index 0000000000..1e6dd0f231 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/PoolSelector.cs @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.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.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Matchmaking; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue +{ + public partial class PoolSelector : CompositeDrawable + { + private const float icon_size = 34; + + public readonly Bindable AvailablePools = new Bindable(); + public readonly Bindable SelectedPool = new Bindable(); + + private FillFlowContainer poolFlow = null!; + + public PoolSelector() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = poolFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + Height = SelectorButton.SIZE.Y + 10, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AvailablePools.BindValueChanged(pools => + { + poolFlow.Clear(); + + foreach (var p in pools.NewValue) + { + poolFlow.Add(new SelectorButton(p) + { + SelectedPool = { BindTarget = SelectedPool }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + }, true); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + var currentSelection = poolFlow.SingleOrDefault(b => b.IsSelected); + + switch (e.Key) + { + case Key.Left: + { + var next = poolFlow.Reverse().SkipWhile(b => b != currentSelection).Skip(1).FirstOrDefault(); + (next ?? poolFlow.Last()).TriggerClickWithSound(); + return true; + } + + case Key.Right: + { + var next = poolFlow.SkipWhile(b => b != currentSelection).Skip(1).FirstOrDefault(); + (next ?? poolFlow.First()).TriggerClickWithSound(); + return true; + } + } + + return false; + } + + private partial class SelectorButton : OsuAnimatedButton + { + public static readonly Vector2 SIZE = new Vector2(84, 64); + + public bool IsSelected => SelectedPool.Value?.Equals(pool) == true; + + public readonly Bindable SelectedPool = new Bindable(); + + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + private readonly MatchmakingPool pool; + private Drawable iconSprite = null!; + + private Box flashLayer = null!; + + private OsuSpriteText text = null!; + + public SelectorButton(MatchmakingPool pool) + : base(HoverSampleSet.ButtonSidebar) + { + this.pool = pool; + + Size = SIZE; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Content.Masking = true; + Content.CornerRadius = 16; + Content.CornerExponent = 10; + + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background2, + Alpha = 0.4f, + RelativeSizeAxes = Axes.Both, + }, + flashLayer = new Box + { + Colour = Color4.White, + Blending = BlendingParameters.Additive, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(5) { Top = 8 }, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(icon_size), + Padding = new MarginPadding(2), + Children = new[] + { + iconSprite = createIcon(), + } + }, + text = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Caption2, + Text = pool.Name, + }, + } + }, + }; + + Action = () => SelectedPool.Value = pool; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedPool.BindValueChanged(onSelectionChanged, true); + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + if (!IsSelected) + flashLayer.FadeTo(0.05f, 200, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + if (!IsSelected) + flashLayer.FadeTo(0f, 200, Easing.OutQuint); + base.OnHoverLost(e); + } + + private void onSelectionChanged(ValueChangedEvent selection) + { + if (IsSelected) + { + this.ScaleTo(1.2f, 200, Easing.OutQuint); + iconSprite.FadeColour(Color4.Gold, 100, Easing.OutQuint); + text.Font = text.Font.With(weight: FontWeight.Bold); + flashLayer.FadeTo(0.1f, 200, Easing.OutQuint); + } + else + { + this.ScaleTo(1f, 200, Easing.OutQuint); + iconSprite.FadeColour(OsuColour.Gray(0.5f), 100); + text.Font = text.Font.With(weight: FontWeight.Regular); + flashLayer.FadeOut(200, Easing.OutQuint); + } + } + + private Drawable createIcon() + { + Ruleset? rulesetInstance = rulesetStore.GetRuleset(pool.RulesetId)?.CreateInstance(); + if (rulesetInstance == null) + return Empty(); + + Drawable icon = rulesetInstance.CreateIcon().With(d => d.RelativeSizeAxes = Axes.Both); + + if (pool.Variant == 0) + return icon; + + return new BufferedContainer(pixelSnapping: true) + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + icon, + new Container + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Size = icon_size * new Vector2(0.4f, 0.28f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = $"{pool.Variant}K", + Font = OsuFont.Default.With(size: icon_size * 0.3f, weight: FontWeight.Bold), + UseFullGlyphHeight = false, + Blending = new BlendingParameters + { + AlphaEquation = BlendingEquation.ReverseSubtract + } + } + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs new file mode 100644 index 0000000000..f72f26f26e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -0,0 +1,231 @@ +// Copyright (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.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Screens.OnlinePlay.Matchmaking.Intro; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue +{ + /// + /// A component which acts as a bridge between the online component (ie ) + /// and the visual representations and flow of queueing for matchmaking. + /// + /// Includes support for deferring to background. + /// + /// + /// This is initialised and cached in the but can be used throughout the system via DI. + public partial class QueueController : Component + { + public readonly Bindable CurrentState = new Bindable(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notifications { get; set; } + + private BackgroundQueueNotification? backgroundNotification; + private bool isBackgrounded; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + client.MatchmakingQueueJoined += onMatchmakingQueueJoined; + client.MatchmakingQueueLeft += onMatchmakingQueueLeft; + client.MatchmakingRoomInvited += onMatchmakingRoomInvited; + client.MatchmakingRoomReady += onMatchmakingRoomReady; + } + + public void SearchInBackground() + { + if (isBackgrounded) + return; + + isBackgrounded = true; + postNotification(); + } + + public void SearchInForeground() + { + if (!isBackgrounded) + return; + + isBackgrounded = false; + closeNotifications(); + } + + private void onRoomUpdated() => Scheduler.Add(() => + { + if (client.Room == null) + CurrentState.Value = ScreenQueue.MatchmakingScreenState.Idle; + }); + + private void onMatchmakingQueueJoined() => Scheduler.Add(() => + { + CurrentState.Value = ScreenQueue.MatchmakingScreenState.Queueing; + + if (isBackgrounded) + { + closeNotifications(); + postNotification(); + } + }); + + private void onMatchmakingQueueLeft() => Scheduler.Add(() => + { + if (CurrentState.Value != ScreenQueue.MatchmakingScreenState.InRoom) + CurrentState.Value = ScreenQueue.MatchmakingScreenState.Idle; + + closeNotifications(); + }); + + private void onMatchmakingRoomInvited() => Scheduler.Add(() => + { + CurrentState.Value = ScreenQueue.MatchmakingScreenState.PendingAccept; + + if (backgroundNotification != null) + { + backgroundNotification.State = ProgressNotificationState.Completed; + backgroundNotification = null; + } + }); + + private void onMatchmakingRoomReady(long roomId, string password) => Scheduler.Add(() => + { + client.JoinRoom(new Room { RoomID = roomId }, password) + .FireAndForget(() => Scheduler.Add(() => + { + CurrentState.Value = ScreenQueue.MatchmakingScreenState.InRoom; + })); + }); + + private void postNotification() + { + if (backgroundNotification != null) + return; + + notifications?.Post(backgroundNotification = new BackgroundQueueNotification(this)); + } + + private void closeNotifications() + { + if (backgroundNotification != null) + { + backgroundNotification.State = ProgressNotificationState.Cancelled; + backgroundNotification.CloseAll(); + backgroundNotification = null; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.RoomUpdated -= onRoomUpdated; + client.MatchmakingQueueJoined -= onMatchmakingQueueJoined; + client.MatchmakingQueueLeft -= onMatchmakingQueueLeft; + client.MatchmakingRoomInvited -= onMatchmakingRoomInvited; + client.MatchmakingRoomReady -= onMatchmakingRoomReady; + } + } + + private partial class BackgroundQueueNotification : ProgressNotification + { + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private readonly QueueController controller; + + private Notification? foundNotification; + private Sample? matchFoundSample; + + public BackgroundQueueNotification(QueueController controller) + { + this.controller = controller; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + Text = "Searching for opponents..."; + + CompletionClickAction = () => + { + client.MatchmakingAcceptInvitation().FireAndForget(); + controller.CurrentState.Value = ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom; + + performer?.PerformFromScreen(s => s.Push(new ScreenIntro())); + + Close(false); + return true; + }; + + CancelRequested = () => + { + client.MatchmakingLeaveQueue().FireAndForget(); + return true; + }; + + matchFoundSample = audio.Samples.Get(@"Multiplayer/Matchmaking/match-found"); + } + + protected override Notification CreateCompletionNotification() + { + // Playing here means it will play even if notification overlay is hidden. + // + // If we add support for the completion notification to be processed during gameplay, + // this can be moved inside the `MatchFoundNotification` implementation. + matchFoundSample?.Play(); + + return foundNotification = new MatchFoundNotification + { + Activated = CompletionClickAction, + Text = "Your match is ready! Click to join.", + }; + } + + public void CloseAll() + { + foundNotification?.Close(false); + Close(false); + } + + public partial class MatchFoundNotification : ProgressCompletionNotification + { + protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; + + public MatchFoundNotification() + { + IsCritical = true; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Icon = FontAwesome.Solid.Bolt; + IconContent.Colour = ColourInfo.GradientVertical(colours.YellowDark, colours.YellowLight); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs new file mode 100644 index 0000000000..8eaa280794 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs @@ -0,0 +1,526 @@ +// Copyright (c) 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; +using System.Threading.Tasks; +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.ObjectExtensions; +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.Framework.Threading; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Matchmaking.Match; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue +{ + /// + /// The initial screen that users arrive at when preparing for a quick play session. + /// + public partial class ScreenQueue : OsuScreen + { + public override bool ShowFooter => true; + + private Container mainContent = null!; + + private MatchmakingScreenState state; + private CloudVisualisation cloud = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IDialogOverlay dialogOverlay { get; set; } = null!; + + [Resolved] + private QueueController controller { get; set; } = null!; + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private MusicController musicController { get; set; } = null!; + + private readonly IBindable currentState = new Bindable(); + + private readonly Bindable availablePools = new Bindable(); + private readonly Bindable selectedPool = new Bindable(); + + private CancellationTokenSource userLookupCancellation = new CancellationTokenSource(); + + private Sample? enqueueSample; + private Sample? waitingLoopSample; + private Sample? matchFoundSample; + + private SampleChannel? waitingLoopChannel; + private ScheduledDelegate? startLoopPlaybackDelegate; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + cloud = new CloudVisualisation + { + Y = -100, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.6f) + }, + new MatchmakingAvatar(api.LocalUser.Value, true) + { + Y = -100, + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new Container + { + RelativePositionAxes = Axes.Y, + Y = 0.25f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + CornerRadius = 10f, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + mainContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 300, + AutoSizeEasing = Easing.OutQuint, + Padding = new MarginPadding(20), + }, + } + }, + }; + + currentState.BindTo(controller.CurrentState); + currentState.BindValueChanged(s => SetState(s.NewValue)); + + client.MatchmakingLobbyStatusChanged += onMatchmakingLobbyStatusChanged; + + populateAvailablePools().FireAndForget(); + } + + private async Task populateAvailablePools() + { + MatchmakingPool[] pools = await client.GetMatchmakingPools().ConfigureAwait(false); + + Schedule(() => + { + availablePools.Value = pools; + + // Default to the user's ruleset for the initial pool selection. + selectedPool.Value = pools.FirstOrDefault(p => p.RulesetId == ruleset.Value.OnlineID) ?? pools.FirstOrDefault(); + }); + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + enqueueSample = audio.Samples.Get(@"Multiplayer/Matchmaking/enqueue"); + waitingLoopSample = audio.Samples.Get(@"Multiplayer/Matchmaking/waiting-loop"); + matchFoundSample = audio.Samples.Get(@"Multiplayer/Matchmaking/match-found"); + } + + private void onMatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status) => Scheduler.Add(() => + { + userLookupCancellation.Cancel(); + var cancellation = userLookupCancellation = new CancellationTokenSource(); + + userLookupCache.GetUsersAsync(status.UsersInQueue, cancellation.Token) + .ContinueWith(result => Schedule(() => + { + APIUser?[] users = result.GetResultSafely(); + if (!cancellation.IsCancellationRequested) + Users = users.OfType().ToArray(); + }), cancellation.Token); + }); + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + controller.SearchInForeground(); + + client.MatchmakingJoinLobby().FireAndForget(); + + using (BeginDelayedSequence(800)) + Schedule(() => SetState(currentState.Value)); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + client.MatchmakingJoinLobby().FireAndForget(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + client.MatchmakingLeaveLobby().FireAndForget(); + } + + private bool exitConfirmed; + private bool isBackgrounded; + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + client.MatchmakingLeaveLobby().FireAndForget(); + + if (isBackgrounded) + return false; + + if (exitConfirmed) + { + client.MatchmakingLeaveQueue().FireAndForget(); + return false; + } + + if (currentState.Value == MatchmakingScreenState.Idle) + return false; + + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else + { + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave the matchmaking queue?", () => + { + exitConfirmed = true; + if (this.IsCurrentScreen()) + this.Exit(); + })); + } + + return true; + } + + public APIUser[] Users + { + set => cloud.Users = value; + } + + public void SetState(MatchmakingScreenState newState) + { + state = newState; + + mainContent.FadeInFromZero(500, Easing.OutQuint); + mainContent.Clear(); + + startLoopPlaybackDelegate?.Cancel(); + stopWaitingLoopPlayback(); + + switch (newState) + { + case MatchmakingScreenState.Idle: + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new PoolSelector + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AvailablePools = { BindTarget = availablePools }, + SelectedPool = { BindTarget = selectedPool } + }, + new BeginQueueingButton(200) + { + DarkerColour = colours.Blue2, + LighterColour = colours.Blue1, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + SelectedPool = { BindTarget = selectedPool }, + Action = () => + { + Debug.Assert(selectedPool.Value != null); + client.MatchmakingJoinQueue(selectedPool.Value.Id).FireAndForget(); + }, + Text = "Begin queueing", + } + } + }; + break; + + case MatchmakingScreenState.Queueing: + ShearedButton sendToBackgroundButton; + + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Waiting for a game...", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + new LoadingSpinner + { + State = { Value = Visibility.Visible }, + }, + sendToBackgroundButton = new ShearedButton(200) + { + DarkerColour = colours.Orange3, + LighterColour = colours.Orange4, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Queue in background", + Action = () => + { + controller.SearchInBackground(); + isBackgrounded = true; + this.Exit(); + }, + Enabled = { Value = false }, + TooltipText = "Wait 5 seconds for this option to become available." + } + } + }; + + Scheduler.AddDelayed(() => + { + if (state != newState) + return; + + sendToBackgroundButton.Enabled.Value = true; + sendToBackgroundButton.TooltipText = "You will receive a notification when your game is ready. Make sure to watch out for it!"; + }, 5000); + + enqueueSample?.Play(); + startLoopPlaybackDelegate = Scheduler.AddDelayed(startWaitingLoopPlayback, 2000); + break; + + case MatchmakingScreenState.PendingAccept: + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Found a match!", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Regular, typeface: Typeface.TorusAlternate), + }, + new SelectionButton(200) + { + DarkerColour = colours.YellowDark, + LighterColour = colours.YellowLight, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => + { + client.MatchmakingAcceptInvitation().FireAndForget(); + SetState(MatchmakingScreenState.AcceptedWaitingForRoom); + }, + Text = "Join match!", + } + } + }; + matchFoundSample?.Play(); + musicController.DuckMomentarily(1250); + break; + + case MatchmakingScreenState.AcceptedWaitingForRoom: + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Waiting for all players...", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + new LoadingSpinner + { + State = { Value = Visibility.Visible }, + }, + } + }; + + startWaitingLoopPlayback(); + break; + + case MatchmakingScreenState.InRoom: + // room received, show users and transition to next screen. + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Good luck!", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + } + }; + + using (BeginDelayedSequence(2000)) + Schedule(() => this.Push(new ScreenMatchmaking(client.Room!))); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(newState), newState, null); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + stopWaitingLoopPlayback(); + + if (client.IsNotNull()) + client.MatchmakingLobbyStatusChanged -= onMatchmakingLobbyStatusChanged; + } + + public enum MatchmakingScreenState + { + Idle, + Queueing, + PendingAccept, + AcceptedWaitingForRoom, + InRoom + } + + private void startWaitingLoopPlayback() + { + stopWaitingLoopPlayback(); + + waitingLoopChannel = waitingLoopSample?.GetChannel(); + if (waitingLoopChannel == null) + return; + + waitingLoopChannel.Looping = true; + waitingLoopChannel?.Play(); + } + + private void stopWaitingLoopPlayback() + { + waitingLoopChannel?.Stop(); + waitingLoopChannel?.Dispose(); + } + + private partial class BeginQueueingButton : SelectionButton + { + public readonly IBindable SelectedPool = new Bindable(); + + public BeginQueueingButton(float? width = null) + : base(width) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedPool.BindValueChanged(p => Enabled.Value = p.NewValue != null, true); + } + } + + private partial class SelectionButton : ShearedButton, IKeyBindingHandler + { + public SelectionButton(float? width = null, float height = DEFAULT_HEIGHT) + : base(width, height) + { + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + TriggerClickWithSound(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs index 9a03a131b4..7e263b06ad 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs @@ -6,9 +6,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Input; using osu.Game.Input.Bindings; -using osu.Game.Localisation; using osu.Game.Online.Rooms; +using osu.Game.Resources.Localisation.Web; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.Play; @@ -19,6 +20,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved(CanBeNull = true)] private ILocalUserPlayInfo? localUserInfo { get; set; } + protected new ChatTextBox TextBox => base.TextBox!; + private readonly IBindable localUserPlaying = new Bindable(); public override bool PropagatePositionalInputSubTree => localUserPlaying.Value != LocalUserPlayingState.Playing; @@ -28,6 +31,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private readonly Bindable expandedFromTextBoxFocus = new Bindable(); private const float height = 100; + private const float width = 260; public override bool PropagateNonPositionalInputSubTree => true; @@ -35,16 +39,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer : base(room, leaveChannelOnDispose: false) { RelativeSizeAxes = Axes.X; - Background.Alpha = 0.2f; + } - TextBox.PlaceholderText = ChatStrings.InGameInputPlaceholder; - TextBox.Focus = () => TextBox.PlaceholderText = Resources.Localisation.Web.ChatStrings.InputPlaceholder; + [BackgroundDependencyLoader] + private void load(RealmKeyBindingStore keyBindingStore) + { + resetPlaceholderText(); + TextBox.Focus = () => TextBox.PlaceholderText = ChatStrings.InputPlaceholder; TextBox.FocusLost = () => { - TextBox.PlaceholderText = ChatStrings.InGameInputPlaceholder; + resetPlaceholderText(); expandedFromTextBoxFocus.Value = false; }; + + void resetPlaceholderText() => TextBox.PlaceholderText = Localisation.ChatStrings.InGameInputPlaceholder(keyBindingStore.GetBindingsStringFor(GlobalAction.ToggleChatFocus)); } protected override bool OnHover(HoverEvent e) => true; // use UI mouse cursor. @@ -58,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer localUserPlaying.BindValueChanged(playing => { - // for now let's never hold focus. this avoid misdirected gameplay keys entering chat. + // for now let's never hold focus. this avoids misdirected gameplay keys entering chat. // note that this is done within this callback as it triggers an un-focus as well. TextBox.HoldFocus = false; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index 0d90d44496..a91b844900 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Threading; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; -using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osuTK; @@ -23,22 +22,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public partial class MatchStartControl : CompositeDrawable { - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; - [Resolved(canBeNull: true)] + [Resolved] private IDialogOverlay? dialogOverlay { get; set; } [Resolved] private MultiplayerClient client { get; set; } = null!; - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private readonly MultiplayerReadyButton readyButton; private readonly MultiplayerCountdownButton countdownButton; @@ -98,9 +90,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.LoadComplete(); - SelectedItem.BindValueChanged(_ => updateState()); client.RoomUpdated += onRoomUpdated; client.LoadRequested += onLoadRequested; + updateState(); } @@ -214,8 +206,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match readyButton.Enabled.Value = countdownButton.Enabled.Value = client.Room.State != MultiplayerRoomState.Closed - && SelectedItem.Value?.ID == client.Room.Settings.PlaylistItemId - && !client.Room.Playlist.Single(i => i.ID == client.Room.Settings.PlaylistItemId).Expired + && !client.Room.CurrentPlaylistItem.Expired && !operationInProgress.Value; // When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready. diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index 2b592bd8b9..b3923ddde3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { @@ -13,14 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private const float ready_button_width = 600; private const float spectate_button_width = 200; - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); - public MultiplayerMatchFooter() { RelativeSizeAxes = Axes.Both; @@ -36,13 +26,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match new MultiplayerSpectateButton { RelativeSizeAxes = Axes.Both, - SelectedItem = selectedItem }, null, new MatchStartControl { RelativeSizeAxes = Axes.Both, - SelectedItem = selectedItem }, null } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 79617f172c..018d36069e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -12,7 +12,6 @@ using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -29,19 +28,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public partial class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay { - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - protected override OsuButton SubmitButton => settings.ApplyButton; protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private MatchSettings settings = null!; public MultiplayerMatchSettingsOverlay(Room room) @@ -56,7 +48,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, SettingsApplied = Hide, - SelectedItem = { BindTarget = SelectedItem } }; protected partial class MatchSettings : CompositeDrawable @@ -65,7 +56,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - public readonly Bindable SelectedItem = new Bindable(); public Action? SettingsApplied; public OsuTextBox NameField = null!; @@ -86,9 +76,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private MultiplayerMatchSubScreen matchSubScreen { get; set; } = null!; - [Resolved] - private IRoomManager manager { get; set; } = null!; - [Resolved] private MultiplayerClient client { get; set; } = null!; @@ -279,18 +266,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { RelativeSizeAxes = Axes.X, Height = DrawableRoomPlaylistItem.HEIGHT, - SelectedItem = { BindTarget = SelectedItem } }, selectBeatmapButton = new RoundedButton { RelativeSizeAxes = Axes.X, Height = 40, Text = "Select beatmap", - Action = () => - { - if (matchSubScreen.IsCurrentScreen()) - matchSubScreen.Push(new MultiplayerMatchSongSelect(matchSubScreen.Room)); - } + Action = () => matchSubScreen.ShowSongSelect() } } } @@ -377,8 +359,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match updateRoomMaxParticipants(); updateRoomAutoStartDuration(); updateRoomPlaylist(); - - drawablePlaylist.Items.BindCollectionChanged((_, __) => room.Playlist = drawablePlaylist.Items.ToArray()); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -456,7 +436,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (!ApplyButton.Enabled.Value) return; - hideError(); + ErrorText.FadeOut(50); Debug.Assert(applyingSettingsOperation == null); applyingSettingsOperation = ongoingOperationTracker.BeginOperation(); @@ -475,32 +455,32 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match .ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) - onSuccess(room); + onSuccess(); else - onError(t.Exception?.AsSingular().Message ?? "Error changing settings."); + onError(t.Exception, "Error changing settings"); })); } else { room.Name = NameField.Text; + room.Password = PasswordTextBox.Text; room.Type = TypePicker.Current.Value; - room.Password = PasswordTextBox.Current.Value; room.QueueMode = QueueModeDropdown.Current.Value; room.AutoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value); room.AutoSkip = AutoSkipCheckbox.Current.Value; + room.Playlist = drawablePlaylist.Items.ToArray(); - if (int.TryParse(MaxParticipantsField.Text, out int max)) - room.MaxParticipants = max; - else - room.MaxParticipants = null; - - manager.CreateRoom(room, onSuccess, onError); + client.CreateRoom(room).ContinueWith(t => Schedule(() => + { + if (t.IsCompletedSuccessfully) + onSuccess(); + else + onError(t.Exception, "Error creating room"); + })); } } - private void hideError() => ErrorText.FadeOut(50); - - private void onSuccess(Room room) => Schedule(() => + private void onSuccess() => Schedule(() => { Debug.Assert(applyingSettingsOperation != null); @@ -510,28 +490,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match applyingSettingsOperation = null; }); - private void onError(string text) => Schedule(() => + private void onError(Exception? exception, string description) { - Debug.Assert(applyingSettingsOperation != null); + if (exception is AggregateException aggregateException) + exception = aggregateException.AsSingular(); - // see https://github.com/ppy/osu-web/blob/2c97aaeb64fb4ed97c747d8383a35b30f57428c7/app/Models/Multiplayer/PlaylistItem.php#L48. - const string not_found_prefix = "beatmaps not found:"; + string message = exception?.GetHubExceptionMessage() ?? $"{description} ({exception?.Message})"; - if (text.StartsWith(not_found_prefix, StringComparison.Ordinal)) + Schedule(() => { - ErrorText.Text = "The selected beatmap is not available online."; - SelectedItem.Value?.MarkInvalid(); - } - else - { - ErrorText.Text = text; - } + Debug.Assert(applyingSettingsOperation != null); - ErrorText.FadeIn(50); + // see https://github.com/ppy/osu-web/blob/2c97aaeb64fb4ed97c747d8383a35b30f57428c7/app/Models/Multiplayer/PlaylistItem.php#L48. + const string not_found_prefix = "beatmaps not found:"; - applyingSettingsOperation.Dispose(); - applyingSettingsOperation = null; - }); + if (message.StartsWith(not_found_prefix, StringComparison.Ordinal)) + ErrorText.Text = "The selected beatmap is not available online."; + else + ErrorText.Text = message; + + ErrorText.FadeIn(50); + + applyingSettingsOperation.Dispose(); + applyingSettingsOperation = null; + }); + } protected override void Dispose(bool isDisposing) { @@ -568,6 +551,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Description("Off")] Off = 0, + [Description("10 seconds")] + Seconds10 = 10, + [Description("30 seconds")] Seconds30 = 30, diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index 3186cf89a4..3f207f6fa1 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -21,12 +21,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public partial class MultiplayerSpectateButton : CompositeDrawable { - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; @@ -36,7 +30,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private MultiplayerClient client { get; set; } = null!; - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private readonly RoundedButton button; private IBindable operationInProgress = null!; @@ -75,7 +68,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.LoadComplete(); - SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload), true); client.RoomUpdated += onRoomUpdated; updateState(); } @@ -119,13 +111,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private CancellationTokenSource? downloadCheckCancellation; + private int? lastDownloadCheckedBeatmapId; + private void checkForAutomaticDownload() { - PlaylistItem? item = SelectedItem.Value; - - downloadCheckCancellation?.Cancel(); - - if (item == null) + if (client.Room == null) return; if (!automaticallyDownload.Value) @@ -140,10 +130,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (client.LocalUser?.State != MultiplayerUserState.Spectating) return; + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + + // This method is called every time anything changes in the room. + // This could result in download requests firing far too often, when we only expect them to fire once per beatmap. + // + // Without this check, we would see especially egregious behaviour when a user has hit the download rate limit. + if (lastDownloadCheckedBeatmapId == item.BeatmapID) + return; + + lastDownloadCheckedBeatmapId = item.BeatmapID; + + downloadCheckCancellation?.Cancel(); + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. beatmapLookupCache - .GetBeatmapAsync(item.Beatmap.OnlineID, (downloadCheckCancellation = new CancellationTokenSource()).Token) + .GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token) .ContinueWith(resolved => Schedule(() => { var beatmapSet = resolved.GetResultSafely()?.BeatmapSet; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs new file mode 100644 index 0000000000..d2c964c967 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Threading; +using osu.Game.Configuration; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match +{ + public partial class MultiplayerUserModSelectOverlay : UserModSelectOverlay + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private ModSettingChangeTracker? modSettingChangeTracker; + private ScheduledDelegate? debouncedModSettingsUpdate; + + public MultiplayerUserModSelectOverlay() + : base(OverlayColourScheme.Plum) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + SelectedMods.BindValueChanged(onSelectedModsChanged); + + updateValidMods(); + } + + private void onRoomUpdated() + { + // Importantly, this is not scheduled because the client must not skip intermediate server states to validate the allowed mods. + updateValidMods(); + } + + private void onSelectedModsChanged(ValueChangedEvent> mods) + { + modSettingChangeTracker?.Dispose(); + + if (client.Room == null) + return; + + client.ChangeUserMods(mods.NewValue).FireAndForget(); + + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += _ => + { + // Debounce changes to mod settings so as to not thrash the network. + debouncedModSettingsUpdate?.Cancel(); + debouncedModSettingsUpdate = Scheduler.AddDelayed(() => + { + if (client.Room == null) + return; + + client.ChangeUserMods(SelectedMods.Value).FireAndForget(); + }, 500); + }; + } + + private void updateValidMods() + { + if (client.Room == null || client.LocalUser == null) + return; + + MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem; + Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance(); + Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(client.Room.Settings.MatchType, currentItem.RequiredMods, currentItem.AllowedMods, currentItem.Freestyle, ruleset); + + // Update the mod panels to reflect the ones which are valid for selection. + IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); + + // Remove any mods that are no longer allowed. + Mod[] newUserMods = SelectedMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); + if (!newUserMods.SequenceEqual(SelectedMods.Value)) + SelectedMods.Value = newUserMods; + + // The active mods include the playlist item's required mods which change separately from the selected mods. + IReadOnlyList newActiveMods = ComputeActiveMods(); + if (!newActiveMods.SequenceEqual(ActiveMods.Value)) + ActiveMods.Value = newActiveMods; + } + + protected override IReadOnlyList ComputeActiveMods() + { + if (client.Room == null || client.LocalUser == null) + return []; + + MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem; + Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance(); + return currentItem.RequiredMods.Select(m => m.ToMod(ruleset)).Concat(base.ComputeActiveMods()).ToArray(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + + modSettingChangeTracker?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs index d18bb011f0..14b1aa38be 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs @@ -18,6 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist public MultiplayerHistoryList() { ShowItemOwners = true; + AllowShowingResults = true; } protected override FillFlowContainer> CreateListFillFlowContainer() => new HistoryFillFlowContainer diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs index 9feee0ae41..5af0fed48f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Online.Multiplayer; @@ -19,32 +20,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist { public readonly Bindable DisplayMode = new Bindable(); - public required Bindable SelectedItem - { - get => selectedItem; - set => selectedItem.Current = value; - } - /// - /// Invoked when an item requests to be edited. + /// Invoked when the user requests to edit an item. /// public Action? RequestEdit; + /// + /// Invoked when the user requests to view the results for an item. + /// + public Action? RequestResults; + [Resolved] private MultiplayerClient client { get; set; } = null!; - private readonly Room room; - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private MultiplayerPlaylistTabControl playlistTabControl = null!; private MultiplayerQueueList queueList = null!; private MultiplayerHistoryList historyList = null!; private bool firstPopulation = true; - public MultiplayerPlaylist(Room room) - { - this.room = room; - } - [BackgroundDependencyLoader] private void load() { @@ -65,17 +58,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist Masking = true, Children = new Drawable[] { - queueList = new MultiplayerQueueList(room) + queueList = new MultiplayerQueueList { RelativeSizeAxes = Axes.Both, - SelectedItem = { BindTarget = selectedItem }, RequestEdit = item => RequestEdit?.Invoke(item) }, historyList = new MultiplayerHistoryList { RelativeSizeAxes = Axes.Both, Alpha = 0, - SelectedItem = { BindTarget = selectedItem } + RequestResults = item => RequestResults?.Invoke(item) } } } @@ -89,10 +81,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist base.LoadComplete(); DisplayMode.BindValueChanged(onDisplayModeChanged, true); + client.ItemAdded += playlistItemAdded; client.ItemRemoved += playlistItemRemoved; client.ItemChanged += playlistItemChanged; client.RoomUpdated += onRoomUpdated; + updateState(); } @@ -121,28 +115,28 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist firstPopulation = false; } + + PlaylistItem? currentItem = client.Room == null ? null : new PlaylistItem(client.Room.CurrentPlaylistItem); + queueList.SelectedItem.Value = currentItem; + historyList.SelectedItem.Value = currentItem; } - private void playlistItemAdded(MultiplayerPlaylistItem item) => Schedule(() => addItemToLists(item)); + private void playlistItemAdded(MultiplayerPlaylistItem item) => Scheduler.Add(() => addItemToLists(item)); - private void playlistItemRemoved(long item) => Schedule(() => removeItemFromLists(item)); + private void playlistItemRemoved(long item) => Scheduler.Add(() => removeItemFromLists(item)); - private void playlistItemChanged(MultiplayerPlaylistItem item) => Schedule(() => + private void playlistItemChanged(MultiplayerPlaylistItem item) => Scheduler.Add(() => { if (client.Room == null) return; - var newApiItem = new PlaylistItem(item); - var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID); + var existingItem = queueList.Items.SingleOrDefault(i => i.ID == item.ID); // Test if the only change between the two playlist items is the order. - if (existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem)) + if (existingItem != null && existingItem.With(playlistOrder: item.PlaylistOrder).Equals(new PlaylistItem(item))) { - // Set the new playlist order directly without refreshing the DrawablePlaylistItem. - existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder; - - // The following isn't really required, but is here for safety and explicitness. - // MultiplayerQueueList internally binds to changes in Playlist to invalidate its own layout, which is mutated on every playlist operation. + // Set the new order directly and refresh the flow layout as an optimisation to avoid refreshing the items' visual state. + existingItem.PlaylistOrder = item.PlaylistOrder; queueList.Invalidate(); } else @@ -154,22 +148,35 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist private void addItemToLists(MultiplayerPlaylistItem item) { - var apiItem = client.Room?.Playlist.SingleOrDefault(i => i.ID == item.ID); - - // Item could have been removed from the playlist while the local player was in gameplay. - if (apiItem == null) + if (client.Room == null) return; if (item.Expired) - historyList.Items.Add(new PlaylistItem(apiItem)); + historyList.Items.Add(new PlaylistItem(item)); else - queueList.Items.Add(new PlaylistItem(apiItem)); + queueList.Items.Add(new PlaylistItem(item)); } - private void removeItemFromLists(long item) + private void removeItemFromLists(long itemId) { - queueList.Items.RemoveAll(i => i.ID == item); - historyList.Items.RemoveAll(i => i.ID == item); + if (client.Room == null) + return; + + queueList.Items.RemoveAll(i => i.ID == itemId); + historyList.Items.RemoveAll(i => i.ID == itemId); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.ItemAdded -= playlistItemAdded; + client.ItemRemoved -= playlistItemRemoved; + client.ItemChanged -= playlistItemChanged; + client.RoomUpdated -= onRoomUpdated; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs index e5d94c5358..a7f3e17efa 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs @@ -32,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist protected override void LoadComplete() { base.LoadComplete(); - QueueItems.BindCollectionChanged((_, _) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true); + QueueItems.BindCollectionChanged((_, _) => Text.Text = QueueItems.Count > 0 ? $"Up next ({QueueItems.Count})" : "Up next", true); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs index 04bb9b69e6..dc6a713908 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs @@ -2,7 +2,6 @@ // 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.Allocation; using osu.Framework.Extensions.ObjectExtensions; @@ -20,50 +19,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist /// public partial class MultiplayerQueueList : DrawableRoomPlaylist { - private readonly Room room; - - private QueueFillFlowContainer flow = null!; - - public MultiplayerQueueList(Room room) + public MultiplayerQueueList() { - this.room = room; ShowItemOwners = true; } - protected override void LoadComplete() - { - base.LoadComplete(); - - room.PropertyChanged += onRoomPropertyChanged; - updateRoomPlaylist(); - } - - private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(Room.Playlist)) - updateRoomPlaylist(); - } - - private void updateRoomPlaylist() - => flow.InvalidateLayout(); - - protected override FillFlowContainer> CreateListFillFlowContainer() => flow = new QueueFillFlowContainer + protected override FillFlowContainer> CreateListFillFlowContainer() => new QueueFillFlowContainer { Spacing = new Vector2(0, 2) }; protected override DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new QueuePlaylistItem(item); - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - room.PropertyChanged -= onRoomPropertyChanged; - } - private partial class QueueFillFlowContainer : FillFlowContainer> { - public new void InvalidateLayout() => base.InvalidateLayout(); - public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderBy(item => item.Model.PlaylistOrder); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index bf316bb3da..eb387b2664 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -8,7 +8,6 @@ using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; namespace osu.Game.Screens.OnlinePlay.Multiplayer @@ -29,11 +28,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onRoomUpdated() { - if (client.Room == null) + if (client.Room == null || client.LocalUser == null) return; - Debug.Assert(client.LocalUser != null); - // If the user exits gameplay before score submission completes, we'll transition to idle when results has been prepared. if (client.LocalUser.State == MultiplayerUserState.Results && this.IsCurrentScreen()) transitionFromResults(); @@ -63,11 +60,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.OnResuming(e); - if (client.Room == null) + if (client.Room == null || client.LocalUser == null) return; - Debug.Assert(client.LocalUser != null); - if (!(e.Last is MultiplayerPlayerLoader playerLoader)) return; @@ -92,13 +87,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Debug.Assert(client.LocalUser != null); if (client.LocalUser.State == MultiplayerUserState.Results) - client.ChangeState(MultiplayerUserState.Idle); + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); } protected override string ScreenTitle => "Multiplayer"; - protected override RoomManager CreateRoomManager() => new MultiplayerRoomManager(); - protected override LoungeSubScreen CreateLounge() => new MultiplayerLoungeSubScreen(); public void Join(Room room, string? password) => Schedule(() => Lounge.Join(room, password)); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerBeatmapAvailabilityTracker.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerBeatmapAvailabilityTracker.cs new file mode 100644 index 0000000000..71d2d36ee2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerBeatmapAvailabilityTracker.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.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerBeatmapAvailabilityTracker : OnlinePlayBeatmapAvailabilityTracker + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() + { + if (client.Room == null) + return; + + PlaylistItem.Value = new PlaylistItem(client.Room.CurrentPlaylistItem); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index 50358ea9d3..5e2619eae3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Bindables; +using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Logging; -using osu.Framework.Screens; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; @@ -15,10 +13,8 @@ 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; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -31,23 +27,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private MultiplayerClient client { get; set; } = null!; private Dropdown roomAccessTypeDropdown = null!; - - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - - // 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 (e.Last is MultiplayerMatchSubScreen match) - { - RoomManager?.RemoveRoom(match.Room); - ListingPollingComponent.PollImmediately(); - } - } + private OsuCheckbox showInProgress = null!; protected override IEnumerable CreateFilterControls() { - roomAccessTypeDropdown = new SlimEnumDropdown + foreach (var control in base.CreateFilterControls()) + yield return control; + + yield return roomAccessTypeDropdown = new SlimEnumDropdown { RelativeSizeAxes = Axes.None, Current = Config.GetBindable(OsuSetting.MultiplayerRoomFilter), @@ -56,7 +43,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer roomAccessTypeDropdown.Current.BindValueChanged(_ => UpdateFilter()); - return base.CreateFilterControls().Append(roomAccessTypeDropdown); + yield return showInProgress = new OsuCheckbox + { + LabelText = "Show in-progress rooms", + RelativeSizeAxes = Axes.None, + Width = 220, + Padding = new MarginPadding { Vertical = 5, }, + Current = Config.GetBindable(OsuSetting.MultiplayerShowInProgressFilter), + }; + + showInProgress.Current.BindValueChanged(_ => UpdateFilter()); + StatusDropdown.Current.BindValueChanged(_ => showInProgress.Alpha = StatusDropdown.Current.Value == RoomModeFilter.Open ? 1 : 0, true); } protected override FilterCriteria CreateFilterCriteria() @@ -64,6 +61,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer var criteria = base.CreateFilterCriteria(); criteria.Category = @"realtime"; criteria.Permissions = roomAccessTypeDropdown.Current.Value; + criteria.Status = showInProgress.Current.Value && criteria.Mode == RoomModeFilter.Open ? null : RoomStatusFilter.Idle; return criteria; } @@ -75,9 +73,28 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Type = MatchType.HeadToHead, }; - protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); + protected override OnlinePlaySubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); - protected override ListingPollingComponent CreatePollingComponent() => new MultiplayerListingPollingComponent(); + protected override void JoinInternal(Room room, string? password, Action onSuccess, Action onFailure) + { + client.JoinRoom(room, password).ContinueWith(result => + { + if (result.IsCompletedSuccessfully) + onSuccess(room); + else + { + Exception? exception = result.Exception?.AsSingular(); + + if (exception?.GetHubExceptionMessage() is string message) + onFailure(message, exception); + else + onFailure($"Failed to join multiplayer room. {exception?.Message}", exception); + } + }); + } + + public override void Close(Room room) + => throw new NotSupportedException("Cannot close multiplayer rooms."); protected override void OpenNewRoom(Room room) { @@ -89,37 +106,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.OpenNewRoom(room); } - - private partial class MultiplayerListingPollingComponent : ListingPollingComponent - { - [Resolved] - private MultiplayerClient client { get; set; } = null!; - - private readonly IBindable isConnected = new Bindable(); - - [BackgroundDependencyLoader] - private void load() - { - isConnected.BindTo(client.IsConnected); - isConnected.BindValueChanged(_ => Scheduler.AddOnce(poll), true); - } - - private void poll() - { - if (isConnected.Value && IsLoaded) - PollImmediately(); - } - - protected override Task Poll() - { - if (!isConnected.Value) - return Task.CompletedTask; - - if (client.Room != null) - return Task.CompletedTask; - - return base.Poll(); - } - } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs new file mode 100644 index 0000000000..846f781cdc --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchFreestyleSelect.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerMatchFreestyleSelect : OnlinePlayFreestyleSelect + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private OngoingOperationTracker operationTracker { get; set; } = null!; + + private readonly IBindable operationInProgress = new Bindable(); + + private LoadingLayer loadingLayer = null!; + private IDisposable? selectionOperation; + + public MultiplayerMatchFreestyleSelect(Room room, PlaylistItem item) + : base(room, item) + { + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(loadingLayer = new LoadingLayer(true)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + operationInProgress.BindTo(operationTracker.InProgress); + operationInProgress.BindValueChanged(_ => updateLoadingLayer(), true); + } + + private void updateLoadingLayer() + { + if (operationInProgress.Value) + loadingLayer.Show(); + else + loadingLayer.Hide(); + } + + protected override bool OnStart() + { + if (operationInProgress.Value) + { + Logger.Log($"{nameof(OnStart)} aborted due to {nameof(operationInProgress)}"); + return false; + } + + if (!base.OnStart()) + return false; + + selectionOperation = operationTracker.BeginOperation(); + + client.ChangeUserStyle(Beatmap.Value.BeatmapInfo.OnlineID, Ruleset.Value.OnlineID) + .FireAndForget(onSuccess: () => + { + selectionOperation.Dispose(); + + Schedule(() => + { + // If an error or server side trigger occurred this screen may have already exited by external means. + if (this.IsCurrentScreen()) + this.Exit(); + }); + }, onError: _ => + { + selectionOperation.Dispose(); + + Schedule(() => + { + Carousel.AllowSelection = true; + }); + }); + + return true; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 4e03c19095..7328e01026 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -11,7 +11,6 @@ using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay.Multiplayer @@ -86,7 +85,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer BeatmapChecksum = item.Beatmap.MD5Hash, RulesetID = item.RulesetID, RequiredMods = item.RequiredMods.ToArray(), - AllowedMods = item.AllowedMods.ToArray() + AllowedMods = item.AllowedMods.ToArray(), + Freestyle = item.Freestyle }; Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem); @@ -121,9 +121,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); - - protected override bool IsValidMod(Mod mod) => base.IsValidMod(mod) && mod.ValidForMultiplayer; - - protected override bool IsValidFreeMod(Mod mod) => base.IsValidFreeMod(mod) && mod.ValidForMultiplayerAsFreeMod; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index edc45dbf7c..16c6a46a9c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -1,27 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Diagnostics; +using System; using System.Linq; 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.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Framework.Threading; +using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Configuration; using osu.Game.Graphics.Cursor; using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -29,239 +34,845 @@ using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; -using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Users; +using osu.Game.Utils; using osuTK; using ParticipantsList = osu.Game.Screens.OnlinePlay.Multiplayer.Participants.ParticipantsList; namespace osu.Game.Screens.OnlinePlay.Multiplayer { [Cached] - public partial class MultiplayerMatchSubScreen : RoomSubScreen, IHandlePresentBeatmap + public partial class MultiplayerMatchSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner, IHandlePresentBeatmap { + /// + /// Footer height. + /// + private const float footer_height = 50; + + /// + /// Padding between content and footer. + /// + private const float footer_padding = 30; + + /// + /// Internal padding of the content. + /// + private const float content_padding = 20; + + /// + /// Padding between columns of the content. + /// + private const float column_padding = 10; + + /// + /// Padding between rows of the content. + /// + private const float row_padding = 10; + public override string Title { get; } public override string ShortTitle => "room"; + public override bool? ApplyModTrackAdjustments => true; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + /// + /// Whether the user has confirmed they want to exit this screen in the presence of unsaved changes. + /// + protected bool ExitConfirmed { get; private set; } + + /// + /// Used for testing - whether the local user style can be edited. + /// False if the beatmap hasn't been downloaded yet, or if freestyle isn't enabled. + /// + internal bool UserStyleEditingEnabled + { + get + { + if (!userStyleDisplayContainer.IsPresent) + return false; + + return userStyleDisplayContainer.SingleOrDefault()?.AllowEditing == true; + } + } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private AudioManager audio { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private MusicController music { get; set; } = null!; + + [Resolved] + private OnlinePlayScreen? parentScreen { get; set; } + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] private MultiplayerClient client { get; set; } = null!; - [Resolved(canBeNull: true)] + [Resolved] private OsuGame? game { get; set; } - private AddItemButton addItemButton = null!; + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); + + private readonly Room room; + + private Drawable roomContent = null!; + private MultiplayerMatchSettingsOverlay settingsOverlay = null!; + + private FillFlowContainer userModsSection = null!; + private MultiplayerUserModSelectOverlay userModsSelectOverlay = null!; + + private FillFlowContainer userStyleSection = null!; + private Container userStyleDisplayContainer = null!; + + private Sample? sampleStart; + private IDisposable? userModsSelectOverlayRegistration; + + private long lastPlaylistItemId; + private bool isRoomJoined; public MultiplayerMatchSubScreen(Room room) - : base(room) { + this.room = room; + Title = room.RoomID == null ? "New room" : room.Name; Activity.Value = new UserActivity.InLobby(room); + + Padding = new MarginPadding { Top = Header.HEIGHT }; + } + + [BackgroundDependencyLoader] + private void load() + { + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Bottom = footer_height + footer_padding + }, + Children = new[] + { + roomContent = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, row_padding), + }, + Content = new[] + { + new Drawable[] + { + new MultiplayerRoomPanel(room) + { + OnEdit = () => settingsOverlay.Show() + } + }, + null, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(content_padding), + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + }, + Content = new[] + { + new Drawable?[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new ParticipantsListHeader() + }, + new Drawable[] + { + new ParticipantsList + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new OverlinedHeader("Beatmap queue") + }, + new Drawable[] + { + new AddItemButton + { + RelativeSizeAxes = Axes.X, + Height = 30, + Text = "Add item", + Action = () => ShowSongSelect() + }, + }, + null, + new Drawable[] + { + new MultiplayerPlaylist + { + RelativeSizeAxes = Axes.Both, + RequestEdit = ShowSongSelect, + RequestResults = showResults + } + }, + new Drawable[] + { + 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, + Height = 30, + Text = "Select", + Action = showUserModSelect, + }, + new MultiplayerUserModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.8f), + }, + } + }, + } + } + }, + new Drawable[] + { + userStyleSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Difficulty"), + userStyleDisplayContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + }, + }, + }, + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new OverlinedHeader("Chat") + }, + new Drawable[] + { + new MatchChatDisplay(room) + { + RelativeSizeAxes = Axes.Both + } + } + } + } + } + } + } + } + } + } + } + }, + settingsOverlay = new MultiplayerMatchSettingsOverlay(room) + } + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = footer_height, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d") // Temporary. + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Child = new MultiplayerMatchFooter() + } + } + } + } + } + }; + + LoadComponent(userModsSelectOverlay = new MultiplayerUserModSelectOverlay + { + Beatmap = { BindTarget = Beatmap } + }); } protected override void LoadComplete() { base.LoadComplete(); - BeatmapAvailability.BindValueChanged(updateBeatmapAvailability, true); - UserMods.BindValueChanged(onUserModsChanged); + userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); - client.LoadRequested += onLoadRequested; client.RoomUpdated += onRoomUpdated; + client.SettingsChanged += onSettingsChanged; + client.ItemChanged += onItemChanged; + client.UserStyleChanged += onUserStyleChanged; + client.UserModsChanged += onUserModsChanged; + client.LoadRequested += onLoadRequested; - if (!client.IsConnected.Value) - handleRoomLost(); + beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); + + onRoomUpdated(); + updateGameplayState(); + updateUserActivity(); } - protected override bool IsConnected => base.IsConnected && client.IsConnected.Value; - - protected override Drawable CreateMainContent() => new Container + /// + /// Responds to changes in the active room to adjust the visibility of the settings and main content. + /// Only the settings overlay is visible while the room isn't created, and only the main content is visible after creation. + /// + private void onRoomUpdated() => Scheduler.AddOnce(() => { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new OsuContextMenuContainer + bool wasRoomJoined = isRoomJoined; + isRoomJoined = client.Room != null; + + // Creating a room. + if (!wasRoomJoined && !isRoomJoined) { - RelativeSizeAxes = Axes.Both, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable?[] - { - // Participants 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, - // Beatmap column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Beatmap") }, - new Drawable[] - { - addItemButton = new AddItemButton - { - RelativeSizeAxes = Axes.X, - Height = 40, - Text = "Add item", - Action = () => OpenSongSelection() - }, - }, - null, - new Drawable[] - { - new MultiplayerPlaylist(Room) - { - RelativeSizeAxes = Axes.Both, - RequestEdit = OpenSongSelection, - SelectedItem = SelectedItem - } - }, - 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), - }, - } - }, - } - }, - }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - } - }, - // Spacer - null, - // Main right column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - } - } - } + roomContent.Hide(); + settingsOverlay.Show(); } - }; + + // Joining a room. + if (!wasRoomJoined && isRoomJoined) + { + roomContent.Show(); + settingsOverlay.Hide(); + } + + // Leaving a room. + if (wasRoomJoined && !isRoomJoined) + { + Logger.Log($"{this} exiting due to loss of room or connection"); + + if (this.IsCurrentScreen()) + this.Exit(); + else + ValidForResume = false; + } + }); /// - /// Opens the song selection screen to add or edit an item. + /// Responds to changes in the room's settings to update the gameplay state and local user's activity. + /// + private void onSettingsChanged(MultiplayerRoomSettings settings) + { + if (settings.PlaylistItemId != lastPlaylistItemId) + { + onActivePlaylistItemChanged(); + lastPlaylistItemId = settings.PlaylistItemId; + } + + updateUserActivity(); + } + + /// + /// Responds to changes in the active playlist item to update the gameplay state. + /// + private void onItemChanged(MultiplayerPlaylistItem item) + { + if (item.ID == client.Room?.Settings.PlaylistItemId) + onActivePlaylistItemChanged(); + } + + /// + /// Responds to changes in the active playlist item resulting from the playlist item being edited or the room settings changing. + /// + private void onActivePlaylistItemChanged() + { + if (client.Room == null) + return; + + // Check if we need to make this the current screen as a result of the beatmap set changing while the user's selecting a style. + if (this.GetChildScreen() is MultiplayerMatchFreestyleSelect) + { + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + + var newBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + + if (!Beatmap.Value.BeatmapSetInfo.Equals(newBeatmap?.BeatmapSet)) + this.MakeCurrent(); + } + + Scheduler.AddOnce(updateGameplayState); + } + + /// + /// Responds to changes in the local user's style to update the gameplay state. + /// + private void onUserStyleChanged(MultiplayerRoomUser user) + { + if (user.Equals(client.LocalUser)) + Scheduler.AddOnce(updateGameplayState); + } + + /// + /// Responds to changes in the local user's mods style to update the gameplay state. + /// + private void onUserModsChanged(MultiplayerRoomUser user) + { + if (user.Equals(client.LocalUser)) + Scheduler.AddOnce(updateGameplayState); + } + + /// + /// Responds to notifications from the server that a gameplay session is ready to attempt to start the gameplay session. + /// + private void onLoadRequested() + { + if (client.Room == null || client.LocalUser == null) + return; + + // In the case of spectating, IMultiplayerClient.LoadRequested can be fired while the game is still spectating a previous session. + // For now, we want to game to switch to the new game so need to request exiting from the play screen. + if (!parentScreen.IsCurrentScreen()) + { + parentScreen.MakeCurrent(); + Schedule(onLoadRequested); + return; + } + + if (!this.IsCurrentScreen()) + { + this.MakeCurrent(); + Schedule(onLoadRequested); + return; + } + + // Ensure all the gameplay states are up-to-date, forgoing any misordering/scheduling shenanigans. + updateGameplayState(); + + // ... And then check that the set gameplay state is valid. + // When spectating, we'll receive LoadRequested() from the server even though we may not yet have the beatmap. + // In that case, this method will be invoked again after availability changes in onBeatmapAvailabilityChanged(). + if (Beatmap.IsDefault) + { + Logger.Log("Aborting gameplay start - beatmap not downloaded."); + return; + } + + // Start the gameplay session. + sampleStart?.Play(); + + int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); + MultiplayerRoomUser[] users = userIds.Select(id => client.Room.Users.First(u => u.UserID == id)).ToArray(); + + // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). + var targetScreen = (Screen?)parentScreen ?? this; + + switch (client.LocalUser.State) + { + case MultiplayerUserState.Spectating: + targetScreen.Push(new MultiSpectatorScreen(room, users.Take(PlayerGrid.MAX_PLAYERS).ToArray())); + break; + + default: + targetScreen.Push(new MultiplayerPlayerLoader(() => new MultiplayerPlayer(room, new PlaylistItem(client.Room.CurrentPlaylistItem), users))); + break; + } + } + + /// + /// Responds to changes in the local user's beatmap availability to notify the server and prepare the gameplay session. + /// + private void onBeatmapAvailabilityChanged(ValueChangedEvent e) + { + if (client.Room == null || client.LocalUser == null) + return; + + client.ChangeBeatmapAvailability(e.NewValue).FireAndForget(); + + switch (e.NewValue.State) + { + case DownloadState.LocallyAvailable: + updateGameplayState(); + + // Optimistically enter spectator if the match is in progress while spectating. + if (client.LocalUser.State == MultiplayerUserState.Spectating && (client.Room.State == MultiplayerRoomState.WaitingForLoad || client.Room.State == MultiplayerRoomState.Playing)) + onLoadRequested(); + break; + + case DownloadState.NotDownloaded: + updateGameplayState(); + + if (client.LocalUser.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + break; + } + } + + /// + /// Updates the local user's activity to publish their presence in the room. + /// + private void updateUserActivity() + { + if (client.Room == null) + return; + + if (Activity.Value is not UserActivity.InLobby existing || existing.RoomName != client.Room.Settings.Name) + Activity.Value = new UserActivity.InLobby(client.Room); + } + + /// + /// Updates the global beatmap/ruleset/mods in preparation for a new gameplay session. + /// + private void updateGameplayState() + { + if (client.Room == null || client.LocalUser == null) + return; + + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + int gameplayBeatmapId = client.LocalUser.BeatmapId ?? item.BeatmapID; + int gameplayRulesetId = client.LocalUser.RulesetId ?? item.RulesetID; + + RulesetInfo ruleset = rulesets.GetRuleset(gameplayRulesetId)!; + Ruleset rulesetInstance = ruleset.CreateInstance(); + + // Update global gameplay state to correspond to the new selection. + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", gameplayBeatmapId); + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = ruleset; + Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray(); + + bool freemods = item.Freestyle || item.AllowedMods.Any(); + bool freestyle = item.Freestyle; + + if (freemods) + userModsSection.Show(); + else + { + userModsSection.Hide(); + userModsSelectOverlay.Hide(); + } + + if (freestyle) + { + userStyleSection.Show(); + + PlaylistItem apiItem = new PlaylistItem(item).With(beatmap: new Optional(new APIBeatmap { OnlineID = gameplayBeatmapId }), ruleset: gameplayRulesetId); + DrawableRoomPlaylistItem? currentDisplay = userStyleDisplayContainer.SingleOrDefault(); + + if (!apiItem.Equals(currentDisplay?.Item)) + { + userStyleDisplayContainer.Child = currentDisplay = new DrawableRoomPlaylistItem(apiItem, true) + { + AllowReordering = false, + RequestEdit = _ => ShowUserStyleSelect() + }; + } + + currentDisplay.AllowEditing = localBeatmap != null; + } + else + userStyleSection.Hide(); + } + + /// + /// Shows the song selection screen to add or edit an item. /// /// An optional playlist item to edit. If null, a new item will be added instead. - internal void OpenSongSelection(PlaylistItem? itemToEdit = null) + public void ShowSongSelect(PlaylistItem? itemToEdit = null) { if (!this.IsCurrentScreen()) return; - this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); + this.Push(new MultiplayerMatchSongSelect(room, itemToEdit)); } - protected override Drawable CreateFooter() => new MultiplayerMatchFooter + /// + /// Shows the user mod selection. + /// + private void showUserModSelect() { - SelectedItem = SelectedItem - }; - - protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room) - { - SelectedItem = SelectedItem - }; - - protected override void UpdateMods() - { - if (SelectedItem.Value == null || client.LocalUser == null || !this.IsCurrentScreen()) + if (!this.IsCurrentScreen()) return; - // update local mods based on room's reported status for the local user (omitting the base call implementation). - // this makes the server authoritative, and avoids the local user potentially setting mods that the server is not aware of (ie. if the match was started during the selection being changed). - var rulesetInstance = Rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); - Debug.Assert(rulesetInstance != null); - Mods.Value = client.LocalUser.Mods.Select(m => m.ToMod(rulesetInstance)).Concat(SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToList(); + userModsSelectOverlay.Show(); } - [Resolved(canBeNull: true)] - private IDialogOverlay? dialogOverlay { get; set; } + /// + /// Shows the user style selection. + /// + public void ShowUserStyleSelect() + { + if (!this.IsCurrentScreen() || client.Room == null || client.LocalUser == null) + return; - private bool exitConfirmed; + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + this.Push(new MultiplayerMatchFreestyleSelect(room, new PlaylistItem(item))); + } + + /// + /// Shows the results screen for a playlist item. + /// + private void showResults(PlaylistItem item) + { + if (!this.IsCurrentScreen() || client.Room == null || client.LocalUser == null) + return; + + // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). + var targetScreen = (Screen?)parentScreen ?? this; + targetScreen.Push(new PlaylistItemUserBestResultsScreen(client.Room.RoomID, item, client.LocalUser.UserID)); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + beginHandlingTrack(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + onLeaving(); + base.OnSuspending(e); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + beginHandlingTrack(); + + // Required to update beatmap/ruleset when resuming from style selection. + updateGameplayState(); + } public override bool OnExiting(ScreenExitEvent e) { - // room has not been created yet or we're offline; exit immediately. - if (client.Room == null || !IsConnected) - return base.OnExiting(e); + if (!ensureExitConfirmed()) + return true; - if (!exitConfirmed && dialogOverlay != null) + client.LeaveRoom().FireAndForget(); + + onLeaving(); + return base.OnExiting(e); + } + + public override bool OnBackButton() + { + if (room.RoomID == null) + { + if (!ensureExitConfirmed()) + return true; + + 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(); + } + + private void onLeaving() + { + // Must hide this overlay because it is added to a global container. + userModsSelectOverlay.Hide(); + + endHandlingTrack(); + } + + /// + /// Handles changes in the track to keep it looping while active. + /// + private void beginHandlingTrack() + { + Beatmap.BindValueChanged(applyLoopingToTrack, true); + } + + /// + /// Stops looping the current track and stops handling further changes to the track. + /// + private void endHandlingTrack() + { + Beatmap.ValueChanged -= applyLoopingToTrack; + Beatmap.Value.Track.Looping = false; + + previewTrackManager.StopAnyPlaying(this); + } + + /// + /// Invoked on changes to the beatmap to loop the track. See: . + /// + /// The beatmap change event. + private void applyLoopingToTrack(ValueChangedEvent beatmap) + { + if (!this.IsCurrentScreen()) + return; + + beatmap.NewValue.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + + /// + /// Prompts the user to discard unsaved changes to the room before exiting. + /// + /// true if the user has confirmed they want to exit. + private bool ensureExitConfirmed() + { + if (ExitConfirmed) + return true; + + if (api.State.Value != APIState.Online || !client.IsConnected.Value) + return true; + + if (dialogOverlay == null) + return true; + + bool hasUnsavedChanges = room.RoomID == null && room.Playlist.Count > 0; + + if (hasUnsavedChanges) + { + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) + { + discardChangesDialog.Flash(); + return false; + } + + dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + { + ExitConfirmed = true; + settingsOverlay.Hide(); + this.Exit(); + })); + + return false; + } + + if (client.Room != null) { if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) confirmDialog.PerformOkAction(); @@ -269,154 +880,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => { - exitConfirmed = true; - if (this.IsCurrentScreen()) - this.Exit(); + ExitConfirmed = true; + this.Exit(); })); } - return true; + return false; } - return base.OnExiting(e); - } - - private ModSettingChangeTracker? modSettingChangeTracker; - private ScheduledDelegate? debouncedModSettingsUpdate; - - private void onUserModsChanged(ValueChangedEvent> mods) - { - modSettingChangeTracker?.Dispose(); - - if (client.Room == null) - return; - - client.ChangeUserMods(mods.NewValue).FireAndForget(); - - modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); - modSettingChangeTracker.SettingChanged += onModSettingsChanged; - } - - private void onModSettingsChanged(Mod mod) - { - // Debounce changes to mod settings so as to not thrash the network. - debouncedModSettingsUpdate?.Cancel(); - debouncedModSettingsUpdate = Scheduler.AddDelayed(() => - { - if (client.Room == null) - return; - - client.ChangeUserMods(UserMods.Value).FireAndForget(); - }, 500); - } - - private void updateBeatmapAvailability(ValueChangedEvent availability) - { - if (client.Room == null) - return; - - client.ChangeBeatmapAvailability(availability.NewValue).FireAndForget(); - - switch (availability.NewValue.State) - { - case DownloadState.LocallyAvailable: - if (client.LocalUser?.State == MultiplayerUserState.Spectating - && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) - { - onLoadRequested(); - } - - break; - - case DownloadState.Unknown: - // Don't do anything rash in an unknown state. - break; - - default: - // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. - if (client.LocalUser?.State == MultiplayerUserState.Ready) - client.ChangeState(MultiplayerUserState.Idle); - break; - } - } - - private void onRoomUpdated() - { - // may happen if the client is kicked or otherwise removed from the room. - if (client.Room == null) - { - handleRoomLost(); - return; - } - - updateCurrentItem(); - - addItemButton.Alpha = localUserCanAddItem ? 1 : 0; - - Scheduler.AddOnce(UpdateMods); - - Activity.Value = new UserActivity.InLobby(Room); - } - - private bool localUserCanAddItem => client.IsHost || Room.QueueMode != QueueMode.HostOnly; - - private void updateCurrentItem() - { - Debug.Assert(client.Room != null); - SelectedItem.Value = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId); - } - - private void handleRoomLost() => Schedule(() => - { - Logger.Log($"{this} exiting due to loss of room or connection"); - - if (this.IsCurrentScreen()) - this.Exit(); - else - ValidForResume = false; - }); - - private void onLoadRequested() - { - // In the case of spectating, IMultiplayerClient.LoadRequested can be fired while the game is still spectating a previous session. - // For now, we want to game to switch to the new game so need to request exiting from the play screen. - if (!ParentScreen.IsCurrentScreen()) - { - ParentScreen.MakeCurrent(); - - Schedule(onLoadRequested); - return; - } - - // The beatmap is queried asynchronously when the selected item changes. - // This is an issue with MultiSpectatorScreen which is effectively in an always "ready" state and receives LoadRequested() callbacks - // even when it is not truly ready (i.e. the beatmap hasn't been selected by the client yet). For the time being, a simple fix to this is to ignore the callback. - // Note that spectator will be entered automatically when the client is capable of doing so via beatmap availability callbacks (see: updateBeatmapAvailability()). - if (client.LocalUser?.State == MultiplayerUserState.Spectating && (SelectedItem.Value == null || Beatmap.IsDefault)) - return; - - if (BeatmapAvailability.Value.State != DownloadState.LocallyAvailable) - return; - - StartPlay(); - } - - protected override Screen CreateGameplayScreen(PlaylistItem selectedItem) - { - 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(Room, users.Take(PlayerGrid.MAX_PLAYERS).ToArray()); - - default: - return new MultiplayerPlayerLoader(() => new MultiplayerPlayer(Room, selectedItem, users)); - } + return true; } public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) @@ -424,33 +896,76 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!this.IsCurrentScreen()) return; - if (!localUserCanAddItem) + if (client.Room == null || client.LocalUser == null) + return; + + if (client.Room.CanAddPlaylistItems(client.LocalUser) != true) return; // If there's only one playlist item and we are the host, assume we want to change it. Else add a new one. - PlaylistItem? itemToEdit = client.IsHost && Room.Playlist.Count == 1 ? Room.Playlist.Single() : null; + PlaylistItem? itemToEdit = client.IsHost && room.Playlist.Count == 1 ? room.Playlist.Single() : null; - OpenSongSelection(itemToEdit); + ShowSongSelect(itemToEdit); // Re-run PresentBeatmap now that we've pushed a song select that can handle it. game?.PresentBeatmap(beatmap.BeatmapSetInfo, b => b.ID == beatmap.BeatmapInfo.ID); } + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagateNonPositionalInputSubTree => base.PropagateNonPositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + protected override BackgroundScreen CreateBackground() => new MultiplayerRoomBackgroundScreen(); + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + userModsSelectOverlayRegistration?.Dispose(); + if (client.IsNotNull()) { client.RoomUpdated -= onRoomUpdated; + client.SettingsChanged -= onSettingsChanged; + client.ItemChanged -= onItemChanged; + client.UserStyleChanged -= onUserStyleChanged; + client.UserModsChanged -= onUserModsChanged; client.LoadRequested -= onLoadRequested; } - - modSettingChangeTracker?.Dispose(); } public partial class AddItemButton : PurpleRoundedButton { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() + { + if (client.Room == null || client.LocalUser == null) + return; + + Alpha = client.Room.CanAddPlaylistItems(client.LocalUser) ? 1 : 0; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 111b453adb..4cc6f3469d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -8,6 +8,8 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; @@ -15,9 +17,10 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Scoring; using osu.Game.Screens.Play; -using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; +using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -33,10 +36,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private IBindable isConnected = null!; private readonly TaskCompletionSource resultsReady = new TaskCompletionSource(); - private readonly MultiplayerRoomUser[] users; private LoadingLayer loadingDisplay = null!; - private MultiplayerGameplayLeaderboard multiplayerLeaderboard = null!; + + [Cached(typeof(IGameplayLeaderboardProvider))] + private readonly MultiplayerLeaderboardProvider leaderboardProvider; + + private GameplayMatchScoreDisplay teamScoreDisplay = null!; + private GameplayChatDisplay chat = null!; /// /// Construct a multiplayer player. @@ -49,13 +56,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, - AllowFailAnimation = false, - AllowSkipping = room.AutoSkip, AutomaticallySkipIntro = room.AutoSkip, - AlwaysShowLeaderboard = true, + ShowLeaderboard = true, }) { - this.users = users; + leaderboardProvider = new MultiplayerLeaderboardProvider(users); } [BackgroundDependencyLoader] @@ -64,35 +69,45 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!LoadedBeatmapSuccessfully) return; + // also applied in `MultiSpectatorPlayer.load()` ScoreProcessor.ApplyNewJudgementsWhenFailed = true; - LoadComponentAsync(new GameplayChatDisplay(Room) + LoadComponentAsync(new FillFlowContainer { - Expanded = { BindTarget = LeaderboardExpandedState }, - }, chat => HUDOverlay.LeaderboardFlow.Insert(2, chat)); + Width = 260, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new Drawable[] + { + chat = new GameplayChatDisplay(Room), + teamScoreDisplay = new GameplayMatchScoreDisplay + { + Expanded = { BindTarget = HUDOverlay.ShowHud }, + Alpha = 0, + } + } + }, HUDOverlay.TopLeftElements.Add); + LoadComponentAsync(new MultiplayerPositionDisplay + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, d => HUDOverlay.BottomRightElements.Insert(-1, d)); + + LoadComponentAsync(leaderboardProvider, loaded => + { + AddInternal(loaded); + + if (loaded.HasTeams) + { + teamScoreDisplay.Alpha = 1; + teamScoreDisplay.Team1Score.BindTarget = leaderboardProvider.TeamScores.First().Value; + teamScoreDisplay.Team2Score.BindTarget = leaderboardProvider.TeamScores.Last().Value; + } + }); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); } - protected override GameplayLeaderboard CreateGameplayLeaderboard() => multiplayerLeaderboard = new MultiplayerGameplayLeaderboard(users); - - protected override void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) - { - Debug.Assert(leaderboard == multiplayerLeaderboard); - - HUDOverlay.LeaderboardFlow.Insert(0, leaderboard); - - if (multiplayerLeaderboard.TeamScores.Count >= 2) - { - LoadComponentAsync(new GameplayMatchScoreDisplay - { - Team1Score = { BindTarget = multiplayerLeaderboard.TeamScores.First().Value }, - Team2Score = { BindTarget = multiplayerLeaderboard.TeamScores.Last().Value }, - Expanded = { BindTarget = HUDOverlay.ShowHud }, - }, scoreDisplay => HUDOverlay.LeaderboardFlow.Insert(1, scoreDisplay)); - } - } - protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); @@ -105,6 +120,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.GameplayStarted += onGameplayStarted; client.ResultsReady += onResultsReady; + client.VoteToSkipIntroPassed += onVoteToSkipIntroPassed; ScoreProcessor.HasCompleted.BindValueChanged(_ => { @@ -121,6 +137,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer failAndBail(); } }), true); + + LocalUserPlaying.BindValueChanged(_ => chat.Expanded.Value = !LocalUserPlaying.Value, true); } protected override void LoadComplete() @@ -130,6 +148,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Debug.Assert(client.Room != null); } + protected override SkipOverlay CreateSkipOverlay(double startTime) => new MultiplayerSkipOverlay(startTime); + protected override void StartGameplay() { // We can enter this screen one of two ways: @@ -143,13 +163,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.LocalUser?.State == MultiplayerUserState.Loaded) { loadingDisplay.Show(); - client.ChangeState(MultiplayerUserState.ReadyForGameplay); + client.ChangeState(MultiplayerUserState.ReadyForGameplay).FireAndForget(); } // This will pause the clock, pending the gameplay started callback from the server. GameplayClockContainer.Reset(); } + protected override void PerformFail() + { + // base logic intentionally suppressed - failing in multiplayer only marks the score with F rank + // see also: `MultiSpectatorPlayer.PerformFail()` + ScoreProcessor.FailScore(Score.ScoreInfo); + } + + protected override void ConcludeFailedScore(Score score) + => throw new NotSupportedException($"{nameof(MultiplayerPlayer)} should never be calling {nameof(ConcludeFailedScore)}. Failing in multiplayer only marks the score with F rank."); + private void failAndBail(string? message = null) { if (!string.IsNullOrEmpty(message)) @@ -191,18 +221,36 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(60))).ConfigureAwait(false); } + protected override void RequestIntroSkip() + { + // If the room is set up such that the intro is automatically skipped, there's no need to vote on it. + if (Configuration.AutomaticallySkipIntro) + { + base.RequestIntroSkip(); + return; + } + + // No base call because we aren't skipping yet. + client.VoteToSkipIntro().FireAndForget(); + } + + private void onVoteToSkipIntroPassed() + { + Schedule(() => PerformIntroSkip(true)); + } + protected override ResultsScreen CreateResults(ScoreInfo score) { Debug.Assert(Room.RoomID != null); - return multiplayerLeaderboard.TeamScores.Count == 2 - ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value, PlaylistItem, multiplayerLeaderboard.TeamScores) + return leaderboardProvider.TeamScores.Count == 2 + ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value, PlaylistItem, leaderboardProvider.TeamScores) { - ShowUserStatistics = true, + IsLocalPlay = true, } : new MultiplayerResultsScreen(score, Room.RoomID.Value, PlaylistItem) { - ShowUserStatistics = true + IsLocalPlay = true, }; } @@ -214,6 +262,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { client.GameplayStarted -= onGameplayStarted; client.ResultsReady -= onResultsReady; + client.VoteToSkipIntroPassed -= onVoteToSkipIntroPassed; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs index 7eb7f6610e..dd9cb56862 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs @@ -18,6 +18,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; + [Resolved] + private OsuGame? game { get; set; } + private Player? player; public MultiplayerPlayerLoader(Func createPlayer) @@ -39,6 +42,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.OnPlayerLoaded(); + game?.Window?.Flash(); + multiplayerClient.ChangeState(MultiplayerUserState.Loaded) .ContinueWith(task => failAndBail(task.Exception?.Message ?? "Server error"), TaskContinuationOptions.NotOnRanToCompletion); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs new file mode 100644 index 0000000000..a2b9db5a06 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs @@ -0,0 +1,195 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +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.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerPositionDisplay : VisibilityContainer + { + private readonly IBindable user = new Bindable(); + private readonly IBindableList scores = new BindableList(); + private readonly BindableBool showLeaderboard = new BindableBool(); + private readonly IBindable localUserPlayingState = new Bindable(); + + private readonly Bindable position = new Bindable(); + + private RollingCounter positionText = null!; + + private Drawable localPlayerMarker = null!; + + private const float marker_size = 5; + private const float width = 90; + + private const float min_alpha = 0.2f; + private const float max_alpha = 0.4f; + + private GameplayLeaderboardScore? userScore; + + protected override bool StartHidden => true; + + [BackgroundDependencyLoader] + private void load(IGameplayLeaderboardProvider leaderboardProvider, IAPIProvider api, OsuConfigManager configManager, GameplayState gameplayState, OsuColour colours) + { + scores.BindTo(leaderboardProvider.Scores); + user.BindTo(api.LocalUser); + configManager.BindWith(OsuSetting.GameplayLeaderboard, showLeaderboard); + localUserPlayingState.BindTo(gameplayState.PlayingState); + + AutoSizeAxes = Axes.Y; + Width = width; + + InternalChildren = new Drawable[] + { + positionText = new PositionCounter + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = 0, + Padding = new MarginPadding { Right = -5 }, + }, + new Container + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + RelativeSizeAxes = Axes.X, + Height = marker_size - 2, + Children = new[] + { + new Circle + { + Colour = ColourInfo.GradientHorizontal( + Color4.White.Opacity(max_alpha), + Color4.White.Opacity(min_alpha) + ), + RelativeSizeAxes = Axes.Both, + Masking = true, + }, + localPlayerMarker = new Circle + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Colour = colours.Blue1, + Size = new Vector2(marker_size), + Blending = BlendingParameters.Additive, + Alpha = 0.4f, + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + user.BindValueChanged(_ => updateScoreBindings()); + scores.BindCollectionChanged((_, __) => updateScoreBindings(), true); + + showLeaderboard.BindValueChanged(_ => updateVisibility()); + localUserPlayingState.BindValueChanged(_ => updateVisibility(), true); + + State.BindValueChanged(_ => updatePosition()); + position.BindValueChanged(_ => updatePosition(), true); + } + + protected override void PopIn() + { + this.FadeIn(500, Easing.OutQuint); + localPlayerMarker.ScaleTo(Vector2.One, 500, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(500, Easing.OutQuint); + localPlayerMarker.ScaleTo(new Vector2(0.8f), 500, Easing.Out); + } + + private void updateVisibility() + { + bool shouldDisplay = userScore != null && (showLeaderboard.Value || localUserPlayingState.Value == LocalUserPlayingState.Break); + + State.Value = shouldDisplay ? Visibility.Visible : Visibility.Hidden; + } + + private void updateScoreBindings() + { + position.UnbindBindings(); + + userScore = scores.SingleOrDefault(s => s.User.Equals(user.Value)); + if (userScore != null) + position.BindTo(userScore.Position); + else + position.Value = null; + + updateVisibility(); + updatePosition(); + } + + private void updatePosition() + { + // only update when visible to delay animations. + if (State.Value != Visibility.Visible) return; + + if (position.Value == null) + { + positionText.Alpha = min_alpha; + positionText.Current.Value = -1; + localPlayerMarker.FadeOut(); + return; + } + + float relativePosition = Math.Clamp((float)(position.Value.Value - 1) / Math.Max(scores.Count - 1, 1), 0, 1); + + positionText.Current.Value = position.Value.Value; + positionText.FadeTo(min_alpha + (max_alpha - min_alpha) * (1 - relativePosition), 1000, Easing.OutPow10); + + localPlayerMarker.FadeIn(); + float markerWidth = Math.Max(marker_size, width / scores.Count); + localPlayerMarker.ResizeWidthTo(markerWidth, 1000, Easing.OutPow10); + localPlayerMarker.MoveToX(markerWidth / 2 + (width - markerWidth) * relativePosition, 1000, Easing.OutPow10); + } + + private partial class PositionCounter : RollingCounter + { + protected override double RollingDuration => Current.Value > 0 ? 1000 : 0; + protected override Easing RollingEasing => Easing.OutPow10; + + protected override LocalisableString FormatCount(int count) + { + if (count <= 0) + return "-"; + + return "#" + base.FormatCount(count); + } + + protected override OsuSpriteText CreateSpriteText() + { + return new OsuSpriteText + { + Font = OsuFont.Torus.With(size: 60, weight: FontWeight.Light, fixedWidth: true), + Spacing = new Vector2(-8, 0), + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomBackgroundScreen.cs new file mode 100644 index 0000000000..6cb3b7c688 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomBackgroundScreen.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.Extensions.ObjectExtensions; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Components; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerRoomBackgroundScreen : OnlinePlayBackgroundScreen + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() => Scheduler.AddOnce(() => + { + if (client.Room == null) + return; + + PlaylistItem = new PlaylistItem(client.Room.CurrentPlaylistItem); + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs deleted file mode 100644 index e16582a6e1..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ /dev/null @@ -1,73 +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.Diagnostics; -using osu.Framework.Allocation; -using osu.Framework.Extensions.ExceptionExtensions; -using osu.Framework.Logging; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; -using osu.Game.Screens.OnlinePlay.Components; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer -{ - public partial class MultiplayerRoomManager : RoomManager - { - [Resolved] - private MultiplayerClient multiplayerClient { get; set; } = null!; - - public override void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) - => base.CreateRoom(room, r => joinMultiplayerRoom(r, r.Password, onSuccess, onError), onError); - - public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - { - if (!multiplayerClient.IsConnected.Value) - { - onError?.Invoke("Not currently connected to the multiplayer server."); - return; - } - - // this is done here as a pre-check to avoid clicking on already closed rooms in the lounge from triggering a server join. - // should probably be done at a higher level, but due to the current structure of things this is the easiest place for now. - if (room.Status is RoomStatusEnded) - { - onError?.Invoke("Cannot join an ended room."); - return; - } - - base.JoinRoom(room, password, r => joinMultiplayerRoom(r, password, onSuccess, onError), onError); - } - - public override void PartRoom() - { - if (JoinedRoom.Value == null) - return; - - base.PartRoom(); - multiplayerClient.LeaveRoom(); - } - - private void joinMultiplayerRoom(Room room, string? password, Action? onSuccess = null, Action? onError = null) - { - Debug.Assert(room.RoomID != null); - - multiplayerClient.JoinRoom(room, password).ContinueWith(t => - { - if (t.IsCompletedSuccessfully) - Schedule(() => onSuccess?.Invoke(room)); - else if (t.IsFaulted) - { - const string message = "Failed to join multiplayer room."; - - if (t.Exception != null) - Logger.Error(t.Exception, message); - - PartRoom(); - Schedule(() => onError?.Invoke(t.Exception?.AsSingular().Message ?? message)); - } - }); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomPanel.cs new file mode 100644 index 0000000000..e52133b46b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomPanel.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + /// + /// A to be displayed in a multiplayer lobby. + /// + public partial class MultiplayerRoomPanel : RoomPanel + { + public Action? OnEdit { get; set; } + + public Drawable ChangeSettingsButton { get; private set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + public MultiplayerRoomPanel(Room room) + : base(room) + { + } + + [BackgroundDependencyLoader] + private void load() + { + ButtonsContainer.Add(ChangeSettingsButton = new PurpleRoundedButton + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(120, 0.7f), + Text = "Change settings", + Action = () => OnEdit?.Invoke() + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() => Scheduler.AddOnce(() => + { + if (client.Room == null || client.LocalUser == null) + return; + + ChangeSettingsButton.Alpha = client.IsHost ? 1 : 0; + SelectedItem.Value = new PlaylistItem(client.Room.CurrentPlaylistItem); + }); + + protected override UpdateableBeatmapBackgroundSprite CreateBackground() => base.CreateBackground().With(d => + { + d.BackgroundLoadDelay = 0; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs index d53e485c86..cdf4e96bad 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -20,7 +19,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private Sample? userJoinedSample; private Sample? userLeftSample; private Sample? userKickedSample; - private MultiplayerRoomUser? host; [BackgroundDependencyLoader] private void load(AudioManager audio) @@ -35,25 +33,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.LoadComplete(); - client.RoomUpdated += onRoomUpdated; client.UserJoined += onUserJoined; client.UserLeft += onUserLeft; client.UserKicked += onUserKicked; - updateState(); - } - - private void onRoomUpdated() => Scheduler.AddOnce(updateState); - - private void updateState() - { - if (EqualityComparer.Default.Equals(host, client.Room?.Host)) - return; - - // only play sound when the host changes from an already-existing host. - if (host != null) - Scheduler.AddOnce(() => hostChangedSample?.Play()); - - host = client.Room?.Host; + client.HostChanged += onHostChanged; } private void onUserJoined(MultiplayerRoomUser user) @@ -65,16 +48,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(() => userKickedSample?.Play()); + private void onHostChanged(MultiplayerRoomUser? host) + { + if (host != null) + Scheduler.AddOnce(() => hostChangedSample?.Play()); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (client.IsNotNull()) { - client.RoomUpdated -= onRoomUpdated; client.UserJoined -= onUserJoined; client.UserLeft -= onUserLeft; client.UserKicked -= onUserKicked; + client.HostChanged -= onHostChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs new file mode 100644 index 0000000000..35e85c3273 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Play; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerSkipOverlay : SkipOverlay + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private Drawable votedIcon = null!; + private OsuSpriteText countText = null!; + + public MultiplayerSkipOverlay(double startTime) + : base(startTime) + { + } + + [BackgroundDependencyLoader] + private void load() + { + FadingContent.AddRange( + [ + votedIcon = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Position = new Vector2(50, 0), + Size = new Vector2(20), + Alpha = 0, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Green + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.5f), + Icon = FontAwesome.Solid.Check + } + } + }, + countText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + Position = new Vector2(0.75f, 0), + Font = OsuFont.Default.With(size: 36, weight: FontWeight.Bold) + } + ]); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.UserLeft += onUserLeft; + client.UserStateChanged += onUserStateChanged; + client.UserVotedToSkipIntro += onUserVotedToSkipIntro; + + updateText(); + } + + private void onUserLeft(MultiplayerRoomUser user) + { + Schedule(updateText); + } + + private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state) + { + Schedule(updateText); + } + + private void onUserVotedToSkipIntro(int userId) => Schedule(() => + { + updateText(); + + countText.ScaleTo(1.5f).ScaleTo(1, 200, Easing.OutSine); + + if (userId == client.LocalUser?.UserID) + { + votedIcon.ScaleTo(1.5f).ScaleTo(1, 200, Easing.OutSine); + votedIcon.FadeInFromZero(100); + } + }); + + private void updateText() + { + if (client.Room == null || client.Room.Settings.AutoSkip) + return; + + int countTotal = client.Room.Users.Count(u => u.State == MultiplayerUserState.Playing); + int countSkipped = client.Room.Users.Count(u => u.State == MultiplayerUserState.Playing && u.VotedToSkipIntro); + int countRequired = countTotal / 2 + 1; + + countText.Text = $"{Math.Min(countRequired, countSkipped)} / {countRequired}"; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.UserLeft -= onUserLeft; + client.UserStateChanged -= onUserStateChanged; + client.UserVotedToSkipIntro -= onUserVotedToSkipIntro; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerUserModDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerUserModDisplay.cs new file mode 100644 index 0000000000..8937feed5e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerUserModDisplay.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerUserModDisplay : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private ModDisplay modDisplay = null!; + + public MultiplayerUserModDisplay() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = modDisplay = new ModDisplay(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() => Scheduler.AddOnce(() => + { + if (client.Room == null || client.LocalUser == null) + return; + + MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem; + Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance(); + Mod[] userMods = client.LocalUser.Mods.Select(m => m.ToMod(ruleset)).ToArray(); + + if (!userMods.SequenceEqual(modDisplay.Current.Value)) + modDisplay.Current.Value = userMods; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 7e42b18240..19868082fa 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -4,16 +4,22 @@ 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.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Logging; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -31,9 +37,17 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantPanel : CompositeDrawable, IHasContextMenu + public partial class ParticipantPanel : PoolableDrawable, IHasContextMenu, IHasCurrentValue { - public readonly MultiplayerRoomUser User; + public const int HEIGHT = 40; + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(new MultiplayerRoomUser(-1)); [Resolved] private IAPIProvider api { get; set; } = null!; @@ -46,25 +60,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private SpriteIcon crown = null!; + private UserCoverBackground userCover = null!; + private UpdateableAvatar userAvatar = null!; + private UpdateableFlag userFlag = null!; + private OsuSpriteText username = null!; + private Container teamFlagContainer = null!; private OsuSpriteText userRankText = null!; + private StyleDisplayIcon userStyleDisplay = null!; private ModDisplay userModsDisplay = null!; private StateDisplay userStateDisplay = null!; private IconButton kickButton = null!; - public ParticipantPanel(MultiplayerRoomUser user) + public ParticipantPanel() { - User = user; - RelativeSizeAxes = Axes.X; - Height = 40; + Height = HEIGHT; } [BackgroundDependencyLoader] private void load() { - var user = User.User; - var backgroundColour = Color4Extensions.FromHex("#33413C"); InternalChild = new GridContainer @@ -90,7 +106,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Colour = Color4Extensions.FromHex("#F7E65D"), Alpha = 0 }, - new TeamDisplay(User), + new TeamDisplay { Current = Current }, new Container { RelativeSizeAxes = Axes.Both, @@ -103,13 +119,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants RelativeSizeAxes = Axes.Both, Colour = backgroundColour }, - new UserCoverBackground + userCover = 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 @@ -119,27 +134,30 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Direction = FillDirection.Horizontal, Children = new Drawable[] { - new UpdateableAvatar + userAvatar = new UpdateableAvatar { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, - User = user }, - new UpdateableFlag + userFlag = new UpdateableFlag { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Size = new Vector2(28, 20), - CountryCode = user?.CountryCode ?? default }, - new OsuSpriteText + teamFlagContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + username = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), - Text = user?.Username ?? string.Empty }, userRankText = new OsuSpriteText { @@ -149,16 +167,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } }, - new Container + new FillFlowContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Right = 70 }, - Child = userModsDisplay = new ModDisplay + Spacing = new Vector2(2), + Children = new Drawable[] { - Scale = new Vector2(0.5f), - ExpansionMode = ExpansionMode.AlwaysContracted, + userStyleDisplay = new StyleDisplayIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + userModsDisplay = new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.5f), + ExpansionMode = ExpansionMode.AlwaysContracted, + } } }, userStateDisplay = new StateDisplay @@ -175,18 +204,51 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Origin = Anchor.Centre, Alpha = 0, Margin = new MarginPadding(4), - Action = () => client.KickUser(User.UserID).FireAndForget(), + Action = () => client.KickUser(current.Value.UserID).FireAndForget(), }, }, } }; } - protected override void LoadComplete() + protected override void PrepareForUse() { - base.LoadComplete(); + base.PrepareForUse(); client.RoomUpdated += onRoomUpdated; + updateUser(); + FinishTransforms(true); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + client.RoomUpdated -= onRoomUpdated; + // this is a safety measure. + // `MultiplayerRoomUser` has equality members overridden to compare by `UserID` only. + // `MultiplayerClient` only delivers updates of fields values to specific object references. + // if this operation is not done here, in a scenario wherein a user quits and rejoins a room, + // it is possible for a single poolable panel to be freed and then used for the same user with the same ID, + // which at bindable level will lead to `current` not changing (because of the overridden equality member), + // which will lead to this instance not showing any updates for the user in question + // because it's associated with an object reference that `MultiplayerClient` is no longer updating. + current.SetDefault(); + } + + private void updateUser() + { + var user = current.Value.User; + + userCover.User = user; + userAvatar.User = user; + userFlag.CountryCode = user?.CountryCode ?? default; + teamFlagContainer.Child = new UpdateableTeamFlag(user?.Team) + { + Size = new Vector2(40, 20) + }; + username.Text = user?.Username ?? string.Empty; + updateState(); } @@ -199,28 +261,42 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; - MultiplayerPlaylistItem? currentItem = client.Room.GetCurrentItem(); - Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; + var user = current.Value; - int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; - userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; - - userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); - - if ((User.BeatmapAvailability.State == DownloadState.LocallyAvailable) && (User.State != MultiplayerUserState.Spectating)) - userModsDisplay.FadeIn(fade_time); - else - userModsDisplay.FadeOut(fade_time); - - kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0; - crown.Alpha = client.Room.Host?.Equals(User) == true ? 1 : 0; - - // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 - // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => + if (client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem) { - userModsDisplay.Current.Value = ruleset != null ? User.Mods.Select(m => m.ToMod(ruleset)).ToList() : Array.Empty(); - }); + int userBeatmapId = user.BeatmapId ?? currentItem.BeatmapID; + int userRulesetId = user.RulesetId ?? currentItem.RulesetID; + Ruleset? userRuleset = rulesets.GetRuleset(userRulesetId)?.CreateInstance(); + + int? currentModeRank = userRuleset == null ? null : user.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank; + userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; + + if (userBeatmapId == currentItem.BeatmapID && userRulesetId == currentItem.RulesetID) + userStyleDisplay.Style = null; + else + userStyleDisplay.Style = (userBeatmapId, userRulesetId); + + // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 + // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. + Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty() : user.Mods.Select(m => m.ToMod(userRuleset)).ToList()); + } + + userStateDisplay.UpdateStatus(user.State, user.BeatmapAvailability); + + if (user.BeatmapAvailability.State == DownloadState.LocallyAvailable && user.State != MultiplayerUserState.Spectating) + { + userModsDisplay.FadeIn(fade_time); + userStyleDisplay.FadeIn(fade_time); + } + else + { + userModsDisplay.FadeOut(fade_time); + userStyleDisplay.FadeOut(fade_time); + } + + kickButton.Alpha = client.IsHost && !user.Equals(client.LocalUser) ? 1 : 0; + crown.Alpha = client.Room.Host?.Equals(user) == true ? 1 : 0; } public MenuItem[]? ContextMenuItems @@ -230,15 +306,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants if (client.Room == null) return null; + var user = current.Value; + // If the local user is targetted. - if (User.UserID == api.LocalUser.Value.Id) + if (user.UserID == api.LocalUser.Value.Id) return null; // If the local user is not the host of the room. if (client.Room.Host?.UserID != api.LocalUser.Value.Id) return null; - int targetUser = User.UserID; + int targetUser = user.UserID; return new MenuItem[] { @@ -262,14 +340,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (client.IsNotNull()) - client.RoomUpdated -= onRoomUpdated; - } - public partial class KickButton : IconButton { public KickButton() @@ -284,5 +354,81 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants IconHoverColour = colours.Red; } } + + private partial class StyleDisplayIcon : CompositeComponent + { + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + public StyleDisplayIcon() + { + AutoSizeAxes = Axes.Both; + } + + private (int beatmap, int ruleset)? style; + + public (int beatmap, int ruleset)? Style + { + get => style; + set + { + if (style == value) + return; + + style = value; + Scheduler.Add(refresh); + } + } + + private CancellationTokenSource? cancellationSource; + + private void refresh() + { + cancellationSource?.Cancel(); + cancellationSource?.Dispose(); + cancellationSource = null; + + if (Style == null) + { + ClearInternal(); + return; + } + + cancellationSource = new CancellationTokenSource(); + CancellationToken token = cancellationSource.Token; + + int localBeatmap = Style.Value.beatmap; + int localRuleset = Style.Value.ruleset; + + Task.Run(async () => + { + try + { + var beatmap = await beatmapLookupCache.GetBeatmapAsync(localBeatmap, token).ConfigureAwait(false); + if (beatmap == null) + return; + + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + InternalChild = new DifficultyIcon(beatmap, rulesets.GetRuleset(localRuleset)) + { + Size = new Vector2(20), + TooltipType = DifficultyIconTooltipType.Extended, + }; + }); + } + catch (Exception e) + { + Logger.Log($"Error while populating participant style icon {e}"); + } + }, token); + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index a9d7f4ab52..7429fc817c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -3,40 +3,34 @@ using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Online.Multiplayer; -using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantsList : CompositeDrawable + public partial class ParticipantsList : VirtualisedListContainer { - private FillFlowContainer panels = null!; - private ParticipantPanel? currentHostPanel; + private BindableList participants => RowData; + + private MultiplayerRoomUser? currentHost; [Resolved] private MultiplayerClient client { get; set; } = null!; - [BackgroundDependencyLoader] - private void load() + public ParticipantsList() + : base(ParticipantPanel.HEIGHT + 1, initialPoolSize: 20) { - InternalChild = new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = panels = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 2) - } - }; } + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer + { + ScrollbarVisible = false, + }; + protected override void LoadComplete() { base.LoadComplete(); @@ -50,36 +44,38 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private void updateState() { if (client.Room == null) - panels.Clear(); + participants.Clear(); else { // Remove panels for users no longer in the room. - foreach (var p in panels) + for (int i = participants.Count - 1; i >= 0; i--) { + var participant = participants[i]; + // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. - if (client.Room.Users.All(u => !ReferenceEquals(p.User, u))) - p.Expire(); + if (client.Room.Users.All(u => !ReferenceEquals(participant, u))) + participants.RemoveAt(i); } // Add panels for all users new to the room. - foreach (var user in client.Room.Users.Except(panels.Select(p => p.User))) - panels.Add(new ParticipantPanel(user)); + foreach (var user in client.Room.Users.Except(participants)) + participants.Add(user); - if (currentHostPanel == null || !currentHostPanel.User.Equals(client.Room.Host)) + if (currentHost == null || !currentHost.Equals(client.Room.Host)) { - // Reset position of previous host back to normal, if one existing. - if (currentHostPanel != null && panels.Contains(currentHostPanel)) - panels.SetLayoutPosition(currentHostPanel, 0); - - currentHostPanel = null; + currentHost = null; // Change position of new host to display above all participants. if (client.Room.Host != null) { - currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(client.Room.Host)); + currentHost = participants.SingleOrDefault(u => u.Equals(client.Room.Host)); + int currentHostIndex = participants.IndexOf(client.Room.Host); - if (currentHostPanel != null) - panels.SetLayoutPosition(currentHostPanel, -1); + if (currentHostIndex > 0) + { + participants.Move(currentHostIndex, 0); + currentHost = participants[0]; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index bd9511d50d..282430d744 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -5,11 +5,13 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.Multiplayer; @@ -19,9 +21,15 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - internal partial class TeamDisplay : CompositeDrawable + internal partial class TeamDisplay : CompositeDrawable, IHasCurrentValue { - private readonly MultiplayerRoomUser user; + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(new MultiplayerRoomUser(-1)); [Resolved] private OsuColour colours { get; set; } = null!; @@ -33,10 +41,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private Drawable box = null!; private Sample? sampleTeamSwap; - public TeamDisplay(MultiplayerRoomUser user) + public TeamDisplay() { - this.user = user; - RelativeSizeAxes = Axes.Y; AutoSizeAxes = Axes.X; Margin = new MarginPadding { Horizontal = 3 }; @@ -69,12 +75,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } }; - if (client.LocalUser?.Equals(user) == true) - { - clickableContent.Action = changeTeam; - clickableContent.TooltipText = "Change team"; - } - sampleTeamSwap = audio.Samples.Get(@"Multiplayer/team-swap"); } @@ -83,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants base.LoadComplete(); client.RoomUpdated += onRoomUpdated; - updateState(); + current.BindValueChanged(_ => updateUser(), true); } private void changeTeam() @@ -96,12 +96,28 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants public int? DisplayedTeam { get; private set; } + private void updateUser() + { + var user = current.Value; + + if (client.LocalUser?.Equals(user) == true) + { + clickableContent.Action = changeTeam; + clickableContent.TooltipText = "Change team"; + } + + // reset to ensure samples don't play + DisplayedTeam = null; + updateState(); + } + private void onRoomUpdated() => Scheduler.AddOnce(updateState); private void updateState() { // we don't have a way of knowing when an individual user's state has updated, so just handle on RoomUpdated for now. + var user = current.Value; var userRoomState = client.Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState; const double duration = 400; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index 8526e11e12..e557c6821b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.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.Threading; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -8,6 +9,7 @@ using osu.Game.Beatmaps; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { @@ -24,6 +26,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); private readonly SpectatorPlayerClock spectatorPlayerClock; + // purposefully cached as empty - the multi spectator screen already has one leaderboard, on the left of all the player instances + [Cached(typeof(IGameplayLeaderboardProvider))] + private readonly EmptyGameplayLeaderboardProvider leaderboardProvider = new EmptyGameplayLeaderboardProvider(); + /// /// Creates a new . /// @@ -42,6 +48,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate if (cancellationToken.IsCancellationRequested) return; + if (!LoadedBeatmapSuccessfully) + return; + + // also applied in `MultiplayerPlayer.load()` + ScoreProcessor.ApplyNewJudgementsWhenFailed = true; + HUDOverlay.PlayerSettingsOverlay.Expire(); HUDOverlay.HoldToQuit.Expire(); } @@ -76,5 +88,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate } protected override ResultsScreen CreateResults(ScoreInfo score) => new MultiSpectatorResultsScreen(score); + + protected override void PerformFail() + { + // base logic intentionally suppressed - failing in multiplayer only marks the score with F rank + // see also: `MultiplayerPlayer.PerformFail()` + ScoreProcessor.FailScore(Score.ScoreInfo); + } + + protected override void ConcludeFailedScore(Score score) + => throw new NotSupportedException($"{nameof(MultiSpectatorPlayer)} should never be calling {nameof(ConcludeFailedScore)}. Failing in multiplayer only marks the score with F rank."); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs index c240bbea0c..3cf1661c8d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using osu.Game.Online.API; +using System.Threading.Tasks; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -23,8 +21,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate Scheduler.AddDelayed(() => StatisticsPanel.ToggleVisibility(), 1000); } - protected override APIRequest? FetchScores(Action> scoresCallback) => null; + protected override Task FetchScores() => Task.FromResult([]); - protected override APIRequest? FetchNextPage(int direction, Action> scoresCallback) => null; + protected override Task FetchNextPage(int direction) => Task.FromResult([]); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 33c3c60ed3..fb9343c519 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -15,6 +15,7 @@ using osu.Game.Online.Rooms; using osu.Game.Online.Spectator; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Screens.Spectate; using osu.Game.Users; using osuTK; @@ -39,6 +40,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public bool AllPlayersLoaded => instances.All(p => p.PlayerLoaded); + internal DrawableGameplayLeaderboard Leaderboard { get; private set; } = null!; + protected override UserActivity InitialActivity => new UserActivity.SpectatingMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); [Resolved] @@ -47,17 +50,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; + [Cached(typeof(IGameplayLeaderboardProvider))] + private MultiSpectatorLeaderboardProvider leaderboardProvider { get; set; } + private IAggregateAudioAdjustment? boundAdjustments; private readonly PlayerArea[] instances; private MasterGameplayClockContainer masterClockContainer = null!; private SpectatorSyncManager syncManager = null!; private PlayerGrid grid = null!; - private MultiSpectatorLeaderboard leaderboard = null!; private PlayerArea? currentAudioSource; private readonly Room room; - private readonly MultiplayerRoomUser[] users; /// /// Creates a new . @@ -68,9 +72,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate : base(users.Select(u => u.UserID).ToArray()) { this.room = room; - this.users = users; instances = new PlayerArea[Users.Count]; + leaderboardProvider = new MultiSpectatorLeaderboardProvider(users); } [BackgroundDependencyLoader] @@ -133,25 +137,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate for (int i = 0; i < Users.Count; i++) grid.Add(instances[i] = new PlayerArea(Users[i], syncManager.CreateManagedClock())); - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(users) - { - Expanded = { Value = true }, - }, _ => + LoadComponentAsync(leaderboardProvider, _ => { + AddInternal(leaderboardProvider); foreach (var instance in instances) - leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock); + leaderboardProvider.AddClock(instance.UserId, instance.SpectatorPlayerClock); - leaderboardFlow.Insert(0, leaderboard); - - if (leaderboard.TeamScores.Count == 2) + if (leaderboardProvider.TeamScores.Count == 2) { LoadComponentAsync(new MatchScoreDisplay { - Team1Score = { BindTarget = leaderboard.TeamScores.First().Value }, - Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value }, + Team1Score = { BindTarget = leaderboardProvider.TeamScores.First().Value }, + Team2Score = { BindTarget = leaderboardProvider.TeamScores.Last().Value }, }, scoreDisplayContainer.Add); } }); + leaderboardFlow.Insert(0, Leaderboard = new DrawableGameplayLeaderboard + { + CollapseDuringGameplay = { Value = false }, + AlwaysShown = true, + }); LoadComponentAsync(new GameplayChatDisplay(room) { @@ -173,17 +178,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { base.Update(); - if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock)) - { - currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock)).MinBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime)); + checkAudioSource(); + } - // Only bind adjustments if there's actually a valid source, else just use the previous ones to ensure no sudden changes to audio. - if (currentAudioSource != null) - bindAudioAdjustments(currentAudioSource); + private void checkAudioSource() + { + // always use the maximised player instance as the current audio source if there is one + if (grid.MaximisedCell?.Content is PlayerArea maximisedPlayer && maximisedPlayer == currentAudioSource) + return; - foreach (var instance in instances) - instance.Mute = instance != currentAudioSource; - } + // if there is no maximised player instance and the previous audio source is still good to use, keep using it + if (grid.MaximisedCell == null && isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock)) + return; + + // at this point we're in one of the following scenarios: + // - the maximised player instance is not the current audio source => we want to switch to the maximised player instance + // - there is no maximised player instance, and the previous audio source is stopped => find another running audio source + currentAudioSource = grid.MaximisedCell?.Content as PlayerArea + ?? instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock)).MinBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime)); + + // Only bind adjustments if there's actually a valid source, else just use the previous ones to ensure no sudden changes to audio. + if (currentAudioSource != null) + bindAudioAdjustments(currentAudioSource); + + foreach (var instance in instances) + instance.Mute = instance != currentAudioSource; } private void bindAudioAdjustments(PlayerArea first) @@ -277,7 +296,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // On a manual exit, set the player back to idle unless gameplay has finished. // Of note, this doesn't cover exiting using alt-f4 or menu home option. if (multiplayerClient.Room.State != MultiplayerRoomState.Open) - multiplayerClient.ChangeState(MultiplayerUserState.Idle); + multiplayerClient.ChangeState(MultiplayerUserState.Idle).FireAndForget(); return base.OnBackButton(); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index 1b03452df7..b8f0a67a46 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -5,9 +5,11 @@ using System; using System.Collections.Generic; 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.Logging; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; @@ -53,13 +55,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public Score? Score { get; private set; } [Resolved] - private IBindable beatmap { get; set; } = null!; + private BeatmapManager beatmapManager { get; set; } = null!; private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); private readonly BindableDouble volumeAdjustment = new BindableDouble(); private readonly Container gameplayContent; private readonly LoadingLayer loadingLayer; private OsuScreenStack? stack; + private Track? loadedTrack; public PlayerArea(int userId, SpectatorPlayerClock clock) { @@ -89,7 +92,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate Score = score; - gameplayContent.Child = new PlayerIsolationContainer(beatmap.Value, Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) + // Required for freestyle, where each player may be playing a different beatmap. + var workingBeatmap = beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.BeatmapInfo); + + // Required to avoid crashes, but we really don't want to be doing this if we can avoid it. + // If we get to fixing this, we will want to investigate every access to `Track` in gameplay. + if (!workingBeatmap.TrackLoaded) + loadedTrack = workingBeatmap.LoadTrack(); + + gameplayContent.Child = new PlayerIsolationContainer(workingBeatmap, Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) { RelativeSizeAxes = Axes.Both, Child = stack = new OsuScreenStack @@ -118,8 +129,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate get => mute; set { + if (mute == value) + return; + mute = value; volumeAdjustment.Value = value ? 0 : 1; + Logger.Log($"{(mute ? "muting" : "unmuting")} player {UserId}"); } } @@ -127,18 +142,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public override bool PropagatePositionalInputSubTree => false; public override bool PropagateNonPositionalInputSubTree => false; + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + loadedTrack?.Dispose(); + } + /// /// Isolates each player instance from the game-wide ruleset/beatmap/mods (to allow for different players having different settings). /// private partial class PlayerIsolationContainer : Container { [Cached] + [Cached(typeof(IBindable))] private readonly Bindable ruleset = new Bindable(); [Cached] + [Cached(typeof(IBindable))] private readonly Bindable beatmap = new Bindable(); [Cached] + [Cached(typeof(IBindable>))] private readonly Bindable> mods = new Bindable>(); public PlayerIsolationContainer(WorkingBeatmap beatmap, RulesetInfo ruleset, IReadOnlyList mods) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.Cell.cs similarity index 93% rename from osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs rename to osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.Cell.cs index bc31299615..d1ba214117 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.Cell.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// A cell of the grid. Contains the content and tracks to the linked facade. /// - private partial class Cell : CompositeDrawable + public partial class Cell : CompositeDrawable { /// /// The index of the original facade of this cell. @@ -33,11 +33,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public Action? ToggleMaximisationState; - /// - /// Whether this cell is currently maximised. - /// - public bool IsMaximised { get; private set; } - private Facade facade; private bool isAnimating; @@ -83,7 +78,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public void SetFacade(Facade newFacade, bool isMaximised) { facade = newFacade; - IsMaximised = isMaximised; isAnimating = true; TweenEdgeEffectTo(new EdgeEffectParameters diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs index 6e71c010e5..c3ad14dba2 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs @@ -31,6 +31,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public Facade MaximisedFacade { get; } + /// + /// The currently-maximised cell. + /// + public Cell? MaximisedCell { get; private set; } + private readonly Container paddingContainer; private readonly FillFlowContainer facadeContainer; private readonly Container cellContainer; @@ -99,7 +104,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate private void toggleMaximisationState(Cell target) { // in the case the target is the already maximised cell (or there is only one cell), no cell should be maximised. - bool hasMaximised = !target.IsMaximised && cellContainer.Count > 1; + bool hasMaximised = target != MaximisedCell && cellContainer.Count > 1; + MaximisedCell = hasMaximised ? target : null; // Iterate through all cells to ensure only one is maximised at any time. foreach (var cell in cellContainer.ToList()) diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayBeatmapAvailabilityTracker.cs similarity index 57% rename from osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs rename to osu.Game/Screens/OnlinePlay/OnlinePlayBeatmapAvailabilityTracker.cs index 45f52f3cd8..ae0b4a9943 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayBeatmapAvailabilityTracker.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; using System.Linq; @@ -16,10 +14,12 @@ using osu.Framework.Logging; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; using Realms; -namespace osu.Game.Online.Rooms +namespace osu.Game.Screens.OnlinePlay { /// /// Represent a checksum-verifying beatmap availability tracker usable for online play screens. @@ -27,9 +27,17 @@ namespace osu.Game.Online.Rooms /// This differs from a regular download tracking composite as this accounts for the /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap. /// - public partial class OnlinePlayBeatmapAvailabilityTracker : CompositeComponent + public abstract partial class OnlinePlayBeatmapAvailabilityTracker : CompositeComponent { - public readonly Bindable SelectedItem = new Bindable(); + /// + /// The current availability of 's beatmap. + /// + public virtual IBindable Availability => availability; // Virtual for mocking in some tests. + + /// + /// The playlist item to track the availability of. + /// + protected readonly Bindable PlaylistItem = new Bindable(); [Resolved] private RealmAccess realm { get; set; } = null!; @@ -37,23 +45,17 @@ namespace osu.Game.Online.Rooms [Resolved] private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; - /// - /// The availability state of the currently selected playlist item. - /// - public virtual IBindable Availability => availability; - private readonly Bindable availability = new Bindable(BeatmapAvailability.NotDownloaded()); - private ScheduledDelegate progressUpdate; - private BeatmapDownloadTracker downloadTracker; - private IDisposable realmSubscription; - private APIBeatmap selectedBeatmap; + private ScheduledDelegate? progressUpdate; + private BeatmapDownloadTracker? downloadTracker; + private IDisposable? realmSubscription; protected override void LoadComplete() { base.LoadComplete(); - SelectedItem.BindValueChanged(item => + PlaylistItem.BindValueChanged(item => { // the underlying playlist is regularly cleared for maintenance purposes (things which probably need to be fixed eventually). // to avoid exposing a state change when there may actually be none, ignore all nulls for now. @@ -69,30 +71,29 @@ namespace osu.Game.Online.Rooms // This is just for safety. availability.Value = BeatmapAvailability.Unknown(); - downloadTracker?.RemoveAndDisposeImmediately(); - selectedBeatmap = null; + cancelTracking(); beatmapLookupCache.GetBeatmapAsync(item.NewValue.Beatmap.OnlineID).ContinueWith(task => Schedule(() => { var beatmap = task.GetResultSafely(); - if (beatmap != null && SelectedItem.Value?.Beatmap.OnlineID == beatmap.OnlineID) - { - selectedBeatmap = beatmap; - beginTracking(); - } + if (beatmap != null && PlaylistItem.Value?.Beatmap.OnlineID == beatmap.OnlineID) + startTracking(beatmap); }), TaskContinuationOptions.OnlyOnRanToCompletion); }, true); } - private void beginTracking() + private void cancelTracking() { - Debug.Assert(selectedBeatmap.BeatmapSet != null); + downloadTracker?.RemoveAndDisposeImmediately(); + realmSubscription?.Dispose(); + } - downloadTracker = new BeatmapDownloadTracker(selectedBeatmap.BeatmapSet); - - AddInternal(downloadTracker); + private void startTracking(APIBeatmap beatmap) + { + Debug.Assert(beatmap.BeatmapSet != null); + downloadTracker = new BeatmapDownloadTracker(beatmap.BeatmapSet); downloadTracker.State.BindValueChanged(_ => Scheduler.AddOnce(updateAvailability), true); downloadTracker.Progress.BindValueChanged(_ => { @@ -105,64 +106,55 @@ namespace osu.Game.Online.Rooms progressUpdate = Scheduler.AddDelayed(updateAvailability, progressUpdate == null ? 0 : 500); }, true); + AddInternal(downloadTracker); + // handles changes to hash that didn't occur from the import process (ie. a user editing the beatmap in the editor, somehow). - realmSubscription?.Dispose(); - realmSubscription = realm.RegisterForNotifications(_ => filteredBeatmaps(), (_, changes) => + realmSubscription = realm.RegisterForNotifications(_ => queryBeatmap(), (_, changes) => { if (changes == null) return; Scheduler.AddOnce(updateAvailability); }); - } - private void updateAvailability() - { - if (downloadTracker == null || selectedBeatmap == null) - return; - - switch (downloadTracker.State.Value) + void updateAvailability() { - case DownloadState.Unknown: - availability.Value = BeatmapAvailability.Unknown(); - break; + switch (downloadTracker.State.Value) + { + case DownloadState.Unknown: + availability.Value = BeatmapAvailability.Unknown(); + break; - case DownloadState.NotDownloaded: - availability.Value = BeatmapAvailability.NotDownloaded(); - break; + case DownloadState.NotDownloaded: + availability.Value = BeatmapAvailability.NotDownloaded(); + break; - case DownloadState.Downloading: - availability.Value = BeatmapAvailability.Downloading((float)downloadTracker.Progress.Value); - break; + case DownloadState.Downloading: + availability.Value = BeatmapAvailability.Downloading((float)downloadTracker.Progress.Value); + break; - case DownloadState.Importing: - availability.Value = BeatmapAvailability.Importing(); - break; + case DownloadState.Importing: + availability.Value = BeatmapAvailability.Importing(); + break; - case DownloadState.LocallyAvailable: - bool available = filteredBeatmaps().Any(); + case DownloadState.LocallyAvailable: + bool available = queryBeatmap().Any(); - availability.Value = available ? BeatmapAvailability.LocallyAvailable() : BeatmapAvailability.NotDownloaded(); + availability.Value = available ? BeatmapAvailability.LocallyAvailable() : BeatmapAvailability.NotDownloaded(); - // only display a message to the user if a download seems to have just completed. - if (!available && downloadTracker.Progress.Value == 1) - Logger.Log("The imported beatmap set does not match the online version.", LoggingTarget.Runtime, LogLevel.Important); + // only display a message to the user if a download seems to have just completed. + if (!available && downloadTracker.Progress.Value == 1) + Logger.Log("The imported beatmap set does not match the online version.", LoggingTarget.Runtime, LogLevel.Important); - break; + break; - default: - throw new ArgumentOutOfRangeException(); + default: + throw new ArgumentOutOfRangeException(); + } } - } - private IQueryable filteredBeatmaps() - { - int onlineId = selectedBeatmap.OnlineID; - string checksum = selectedBeatmap.MD5Hash; - - return realm.Realm - .All() - .Filter("OnlineID == $0 && MD5Hash == $1 && BeatmapSet.DeletePending == false", onlineId, checksum); + IQueryable queryBeatmap() => + realm.Realm.All().Filter("OnlineID == $0 && MD5Hash == $1 && BeatmapSet.DeletePending == false", beatmap.OnlineID, beatmap.MD5Hash); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs new file mode 100644 index 0000000000..13ac406396 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayFreestyleSelect.cs @@ -0,0 +1,138 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osu.Game.Users; + +namespace osu.Game.Screens.OnlinePlay +{ + public abstract partial class OnlinePlayFreestyleSelect : SongSelect, IOnlinePlaySubScreen, IHandlePresentBeatmap + { + public string ShortTitle => "style selection"; + + public override string Title => ShortTitle.Humanize(); + + public override bool AllowEditing => false; + + protected override UserActivity InitialActivity => new UserActivity.InLobby(room); + + private readonly Room room; + private readonly PlaylistItem item; + + protected OnlinePlayFreestyleSelect(Room room, PlaylistItem item) + { + this.room = room; + this.item = item; + + Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; + } + + [BackgroundDependencyLoader] + private void load() + { + LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; + } + + protected override bool OnStart() + { + FilterCriteria criteria = FilterControl.CreateCriteria(); + + // Beatmaps with too different of a duration are filtered away; this is just a final safety. + if (!criteria.Length.IsInRange(Beatmap.Value.BeatmapInfo.Length)) + { + Logger.Log("The selected beatmap's duration differs too much from the host's selection.", level: LogLevel.Error); + return false; + } + + // Beatmaps without a valid online ID are filtered away; this is just a final safety. + if (Beatmap.Value.BeatmapInfo.OnlineID < 0) + { + Logger.Log("The selected beatmap is not available online.", level: LogLevel.Error); + return false; + } + + // Beatmaps from different sets are filtered away; this is just a final safety. + if (Beatmap.Value.BeatmapSetInfo.OnlineID != criteria.BeatmapSetId) + { + Logger.Log("The selected beatmap is from a different beatmap set.", level: LogLevel.Error); + return false; + } + + if (Ruleset.Value.OnlineID < 0) + { + Logger.Log("The selected ruleset is not available online.", level: LogLevel.Error); + return false; + } + + return true; + } + + protected override FilterControl CreateFilterControl() => new DifficultySelectFilterControl(item); + + protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() + { + // Required to create the drawable components. + base.CreateSongSelectFooterButtons(); + return Enumerable.Empty<(FooterButton, OverlayContainer?)>(); + } + + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + // This screen cannot present beatmaps. + } + + private partial class DifficultySelectFilterControl : FilterControl + { + private readonly PlaylistItem item; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public DifficultySelectFilterControl(PlaylistItem item) + { + this.item = item; + } + + public override FilterCriteria CreateCriteria() + { + var criteria = base.CreateCriteria(); + + double itemLength = 0; + int beatmapSetId = 0; + + realm.Run(r => + { + int beatmapId = item.Beatmap.OnlineID; + BeatmapInfo? beatmap = r.All().FirstOrDefault(b => b.OnlineID == beatmapId); + + itemLength = beatmap?.Length ?? 0; + beatmapSetId = beatmap?.BeatmapSet?.OnlineID ?? 0; + }); + + // Must be from the same set as the playlist item. + criteria.BeatmapSetId = beatmapSetId; + criteria.HasOnlineID = true; + + // Must be within 30s of the playlist item. + criteria.Length.Min = itemLength - 30000; + criteria.Length.Max = itemLength + 30000; + criteria.Length.IsLowerInclusive = true; + criteria.Length.IsUpperInclusive = true; + return criteria; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 17fb667e14..cb14680626 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -11,7 +11,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Screens.Menu; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Users; @@ -36,9 +35,6 @@ namespace osu.Game.Screens.OnlinePlay private readonly ScreenStack screenStack = new OnlinePlaySubScreenStack { RelativeSizeAxes = Axes.Both }; private OnlinePlayScreenWaveContainer waves = null!; - [Cached(Type = typeof(IRoomManager))] - protected RoomManager RoomManager { get; private set; } - [Cached] private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); @@ -51,8 +47,6 @@ namespace osu.Game.Screens.OnlinePlay Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; - - RoomManager = CreateRoomManager(); } private readonly IBindable apiState = new Bindable(); @@ -67,7 +61,6 @@ namespace osu.Game.Screens.OnlinePlay { screenStack, new Header(ScreenTitle, screenStack), - RoomManager, ongoingOperationTracker, } }; @@ -113,6 +106,8 @@ namespace osu.Game.Screens.OnlinePlay public override void OnEntering(ScreenTransitionEvent e) { + base.OnEntering(e); + this.FadeIn(); waves.Show(); @@ -126,6 +121,8 @@ namespace osu.Game.Screens.OnlinePlay public override void OnResuming(ScreenTransitionEvent e) { + base.OnResuming(e); + this.FadeIn(250); this.ScaleTo(1, 250, Easing.OutSine); @@ -136,12 +133,12 @@ namespace osu.Game.Screens.OnlinePlay // to work around this, do not proxy resume to screens that haven't loaded yet. if ((screenStack.CurrentScreen as Drawable)?.IsLoaded == true) screenStack.CurrentScreen.OnResuming(e); - - base.OnResuming(e); } public override void OnSuspending(ScreenTransitionEvent e) { + base.OnSuspending(e); + this.ScaleTo(1.1f, 250, Easing.InSine); this.FadeOut(250); @@ -159,20 +156,19 @@ namespace osu.Game.Screens.OnlinePlay while (screenStack.CurrentScreen != null && screenStack.CurrentScreen is not LoungeSubScreen) { var subScreen = (Screen)screenStack.CurrentScreen; - if (subScreen.IsLoaded && subScreen.OnExiting(e)) - return true; subScreen.Exit(); - } - RoomManager.PartRoom(); + // If it's still current after calling Exit(), it must have blocked OnExiting(). + if (subScreen.IsCurrentScreen()) + return true; + } waves.Hide(); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); - base.OnExiting(e); - return false; + return base.OnExiting(e); } public override bool OnBackButton() @@ -224,8 +220,6 @@ namespace osu.Game.Screens.OnlinePlay protected abstract string ScreenTitle { get; } - protected virtual RoomManager CreateRoomManager() => new RoomManager(); - protected abstract LoungeSubScreen CreateLounge(); ScreenStack IHasSubScreenStack.SubScreenStack => screenStack; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index f6b6dfd3ab..bb6d75fa3b 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; using osu.Game.Users; using osu.Game.Utils; +using osu.Game.Localisation; namespace osu.Game.Screens.OnlinePlay { @@ -41,10 +42,12 @@ namespace osu.Game.Screens.OnlinePlay protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); + protected readonly Bindable Freestyle = new Bindable(true); private readonly Room room; private readonly PlaylistItem? initialItem; - private readonly FreeModSelectOverlay freeModSelectOverlay; + private readonly FreeModSelectOverlay freeModSelect; + private FooterButton freeModsFooterButton = null!; private IDisposable? freeModSelectOverlayRegistration; @@ -61,10 +64,10 @@ namespace osu.Game.Screens.OnlinePlay Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; - freeModSelectOverlay = new FreeModSelectOverlay + freeModSelect = new FreeModSelectOverlay { SelectedMods = { BindTarget = FreeMods }, - IsValidMod = IsValidFreeMod, + IsValidMod = isValidAllowedMod, }; } @@ -72,7 +75,7 @@ namespace osu.Game.Screens.OnlinePlay private void load() { LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; - LoadComponent(freeModSelectOverlay); + LoadComponent(freeModSelect); } protected override void LoadComplete() @@ -108,25 +111,78 @@ namespace osu.Game.Screens.OnlinePlay Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); } + + Freestyle.Value = initialItem.Freestyle; } - Mods.BindValueChanged(onModsChanged); + Mods.BindValueChanged(onGlobalModsChanged); Ruleset.BindValueChanged(onRulesetChanged); + Freestyle.BindValueChanged(onFreestyleChanged); - freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelectOverlay); + freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect); + + updateFooterButtons(); + updateValidMods(); } - private void onModsChanged(ValueChangedEvent> mods) + private void onFreestyleChanged(ValueChangedEvent enabled) { - FreeMods.Value = FreeMods.Value.Where(checkCompatibleFreeMod).ToList(); + updateFooterButtons(); + updateValidMods(); - // Reset the validity delegate to update the overlay's display. - freeModSelectOverlay.IsValidMod = IsValidFreeMod; + if (enabled.NewValue) + { + // Freestyle allows all mods to be selected as freemods. This does not play nicely for some components: + // - We probably don't want to store a gigantic list of acronyms to the database. + // - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it. + // Instead, freestyle inherently assumes this list is empty, and must be empty for server-side validation to pass. + FreeMods.Value = []; + } + else + { + // When disabling freestyle, enable freemods by default. + FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); + } + } + + private void onGlobalModsChanged(ValueChangedEvent> mods) + { + updateValidMods(); } private void onRulesetChanged(ValueChangedEvent ruleset) { - FreeMods.Value = Array.Empty(); + // Todo: We can probably attempt to preserve across rulesets like the global mods do. + FreeMods.Value = []; + } + + private void updateFooterButtons() + { + if (Freestyle.Value) + { + freeModsFooterButton.Enabled.Value = false; + freeModSelect.Hide(); + } + else + freeModsFooterButton.Enabled.Value = true; + } + + /// + /// Removes invalid mods from and , + /// and updates mod selection overlays to display the new mods valid for selection. + /// + private void updateValidMods() + { + Mod[] validMods = Mods.Value.Where(isValidRequiredMod).ToArray(); + if (!validMods.SequenceEqual(Mods.Value)) + Mods.Value = validMods; + + Mod[] validFreeMods = FreeMods.Value.Where(isValidAllowedMod).ToArray(); + if (!validFreeMods.SequenceEqual(FreeMods.Value)) + FreeMods.Value = validFreeMods; + + ModSelect.IsValidMod = isValidRequiredMod; + freeModSelect.IsValidMod = isValidAllowedMod; } protected sealed override bool OnStart() @@ -135,7 +191,8 @@ namespace osu.Game.Screens.OnlinePlay { RulesetID = Ruleset.Value.OnlineID, RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() + AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), + Freestyle = Freestyle.Value }; return SelectItem(item); @@ -150,9 +207,9 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnBackButton() { - if (freeModSelectOverlay.State.Value == Visibility.Visible) + if (freeModSelect.State.Value == Visibility.Visible) { - freeModSelectOverlay.Hide(); + freeModSelect.Hide(); return true; } @@ -161,42 +218,52 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnExiting(ScreenExitEvent e) { - freeModSelectOverlay.Hide(); + freeModSelect.Hide(); return base.OnExiting(e); } protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) { - IsValidMod = IsValidMod + IsValidMod = isValidRequiredMod }; - protected override IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons() + protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() { var baseButtons = base.CreateSongSelectFooterButtons().ToList(); - var freeModsButton = new FooterButtonFreeMods(freeModSelectOverlay) { Current = FreeMods }; - baseButtons.Insert(baseButtons.FindIndex(b => b.Item1 is FooterButtonMods) + 1, (freeModsButton, freeModSelectOverlay)); + baseButtons.Single(i => i.button is FooterButtonMods).button.TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; + + baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[] + { + (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) + { + FreeMods = { BindTarget = FreeMods }, + Freestyle = { BindTarget = Freestyle } + }, null), + (new FooterButtonFreestyle + { + Freestyle = { BindTarget = Freestyle } + }, null) + }); return baseButtons; } /// - /// Checks whether a given is valid for global selection. + /// Checks whether a given is valid to be selected as a required mod. /// /// The to check. - /// Whether is a valid mod for online play. - protected virtual bool IsValidMod(Mod mod) => mod.HasImplementation && ModUtils.FlattenMod(mod).All(m => m.UserPlayable); + private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value); /// - /// Checks whether a given is valid for per-player free-mod selection. + /// Checks whether a given is valid to be selected as an allowed mod. /// /// The to check. - /// Whether is a selectable free-mod. - protected virtual bool IsValidFreeMod(Mod mod) => IsValidMod(mod) && checkCompatibleFreeMod(mod); - - private bool checkCompatibleFreeMod(Mod mod) - => Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must not be contained in the required mods. - && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); // Mod must be compatible with all the required mods. + private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value) + // Mod must not be contained in the required mods. + && Mods.Value.All(m => m.Acronym != mod.Acronym) + // Mod must be compatible with all the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs index fa1ee004c9..9b35a794a3 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -15,9 +14,6 @@ namespace osu.Game.Screens.OnlinePlay protected sealed override bool PlayExitSound => false; - [Resolved] - protected IRoomManager? RoomManager { get; private set; } - protected OnlinePlaySubScreen() { Anchor = Anchor.Centre; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs index 6695c97508..79baa490ac 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs @@ -1,27 +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.Diagnostics; +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Lounge; namespace osu.Game.Screens.OnlinePlay { public partial class OnlinePlaySubScreenStack : OsuScreenStack { + private OsuScreenDependencies dependencies = null!; + + // Note - these bindables must be stored to fields of this component to be correctly unbound on disposal. + private Bindable beatmap = null!; + private Bindable ruleset = null!; + private Bindable> mods = null!; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + // Bindables are leased by the OnlinePlayScreen, but pulled locally in order to not rely on screen load timings. + // They will all be initially enabled while there is no screen in this stack. + dependencies = new OsuScreenDependencies(true, base.CreateChildDependencies(parent)) + { + Beatmap = { Disabled = false }, + Ruleset = { Disabled = false }, + Mods = { Disabled = false } + }; + + beatmap = dependencies.Beatmap; + ruleset = dependencies.Ruleset; + mods = dependencies.Mods; + + return dependencies; + } + protected override void ScreenChanged(IScreen prev, IScreen? next) { base.ScreenChanged(prev, next); - // because this is a screen stack within a screen stack, let's manually handle disabled changes to simplify things. - var osuScreen = next as OsuScreen; + if (next is not OsuScreen osuNext) + throw new InvalidOperationException("There must always be an online play subscreen."); - Debug.Assert(osuScreen != null); + // See: OnlinePlayScreen.DisallowExternalBeatmapRulesetChanges. + // + // Bindable leases are held by the OnlinePlayScreen and NOT by the subscreens, + // because PlayerLoader needs to resolve LeasedBindables to function correctly. + // + // An unfortunate consequence of this is we need to manually control bindable + // enablement depending on what effect the subscreens want. + // + // This is a two-part process... - bool disallowChanges = osuScreen.DisallowExternalBeatmapRulesetChanges; + // First, emulate the behaviour of DisallowExternalBeatmapRulesetChanges to disable toolbar buttons. + beatmap.Disabled = osuNext.DisallowExternalBeatmapRulesetChanges; + ruleset.Disabled = osuNext.DisallowExternalBeatmapRulesetChanges; + mods.Disabled = osuNext.DisallowExternalBeatmapRulesetChanges; - osuScreen.Beatmap.Disabled = disallowChanges; - osuScreen.Ruleset.Disabled = disallowChanges; - osuScreen.Mods.Disabled = disallowChanges; + // Second, when an OsuScreen is exited with DisallowExternalBeatmapRulesetChanges=true, leased bindables + // are normally returned which reverts the mod and ruleset bindables to their original states. + // + // The exact behaiour of the revert is awkward to emulate, but we particularly care about resetting mods + // when returning to the lounge so that they don't stick around if the user then goes to create a new room. + if (next is LoungeSubScreen) + mods.Value = []; } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.cs new file mode 100644 index 0000000000..47629981f1 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/AddPlaylistToCollectionButton.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.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using Realms; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class AddPlaylistToCollectionButton : RoundedButton + { + private readonly Room room; + + private IDisposable? beatmapSubscription; + private IDisposable? collectionSubscription; + + private Live? collection; + private HashSet localBeatmapHashes = new HashSet(); + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved(canBeNull: true)] + private INotificationOverlay? notifications { get; set; } + + public AddPlaylistToCollectionButton(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() + { + Action = () => + { + if (room.Playlist.Count == 0) + return; + + int countBefore = 0; + int countAfter = 0; + + Text = "Updating collection..."; + Enabled.Value = false; + + realm.WriteAsync(r => + { + var beatmaps = getBeatmapsForPlaylist(r).ToArray(); + var c = getCollectionsForPlaylist(r).FirstOrDefault() + ?? r.Add(new BeatmapCollection(room.Name)); + + countBefore = c.BeatmapMD5Hashes.Count; + + foreach (var item in beatmaps) + { + if (!c.BeatmapMD5Hashes.Contains(item.MD5Hash)) + c.BeatmapMD5Hashes.Add(item.MD5Hash); + } + + countAfter = c.BeatmapMD5Hashes.Count; + }).ContinueWith(_ => Schedule(() => + { + if (countBefore == 0) + notifications?.Post(new SimpleNotification { Text = $"Created new collection \"{room.Name}\" with {countAfter} beatmaps." }); + else + notifications?.Post(new SimpleNotification { Text = $"Added {countAfter - countBefore} beatmaps to collection \"{room.Name}\"." }); + })); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // will be updated via updateButtonState() when ready. + Enabled.Value = false; + + if (room.Playlist.Count == 0) + return; + + beatmapSubscription = realm.RegisterForNotifications(getBeatmapsForPlaylist, (sender, _) => + { + localBeatmapHashes = sender.Select(b => b.MD5Hash).ToHashSet(); + Schedule(updateButtonState); + }); + + collectionSubscription = realm.RegisterForNotifications(getCollectionsForPlaylist, (sender, _) => + { + collection = sender.FirstOrDefault()?.ToLive(realm); + Schedule(updateButtonState); + }); + } + + private void updateButtonState() + { + int countToAdd = getCountToBeAdded(); + + if (collection == null) + Text = $"Create new collection with {countToAdd} beatmaps"; + else if (hasAllItemsInCollection) + Text = "Collection complete!"; + else + Text = $"Add {countToAdd} beatmaps to collection"; + + Enabled.Value = countToAdd > 0; + } + + private int getCountToBeAdded() + { + if (collection == null) + return localBeatmapHashes.Count; + + return collection.PerformRead(c => + { + int count = localBeatmapHashes.Count; + + foreach (string hash in localBeatmapHashes) + { + if (c.BeatmapMD5Hashes.Contains(hash)) + count--; + } + + return count; + }); + } + + private IQueryable getCollectionsForPlaylist(Realm r) => r.All().Where(c => c.Name == room.Name); + + private IQueryable getBeatmapsForPlaylist(Realm r) + { + return r.All().Filter(string.Join(" OR ", room.Playlist.Select(item => $"(OnlineID == {item.Beatmap.OnlineID})").Distinct())); + } + + private bool hasAllItemsInCollection + { + get + { + if (collection == null) + return false; + + return room.Playlist.DistinctBy(i => i.Beatmap.OnlineID).Count() == + collection.PerformRead(c => c.BeatmapMD5Hashes.Count); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + beatmapSubscription?.Dispose(); + collectionSubscription?.Dispose(); + } + + public override LocalisableString TooltipText + { + get + { + if (Enabled.Value) + return string.Empty; + + if (hasAllItemsInCollection) + return "All beatmaps have been added!"; + + return "Download some beatmaps first."; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs index 81ae51bd1b..e994299606 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs @@ -5,14 +5,21 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; +using osu.Game.Models; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Ranking; namespace osu.Game.Screens.OnlinePlay.Playlists @@ -28,6 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private MultiplayerScores? higherScores; private MultiplayerScores? lowerScores; + private WorkingBeatmap itemBeatmap = null!; [Resolved] protected IAPIProvider API { get; private set; } = null!; @@ -38,6 +46,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] protected RulesetStore Rulesets { get; private set; } = null!; + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + protected PlaylistItemResultsScreen(ScoreInfo? score, long roomId, PlaylistItem playlistItem) : base(score) { @@ -48,6 +62,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [BackgroundDependencyLoader] private void load() { + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", + PlaylistItem.Beatmap.OnlineID); + itemBeatmap = beatmapManager.GetWorkingBeatmap(localBeatmap); + AddInternal(new Container { RelativeSizeAxes = Axes.Both, @@ -76,16 +94,21 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected abstract APIRequest CreateScoreRequest(); - protected sealed override APIRequest FetchScores(Action> scoresCallback) + protected override async Task FetchScores() { // This performs two requests: // 1. A request to show the relevant score (and scores around). // 2. If that fails, a request to index the room starting from the highest score. + var requestTaskSource = new TaskCompletionSource(); var userScoreReq = CreateScoreRequest(); + userScoreReq.Success += requestTaskSource.SetResult; + userScoreReq.Failure += requestTaskSource.SetException; + API.Queue(userScoreReq); - userScoreReq.Success += userScore => + try { + var userScore = await requestTaskSource.Task.ConfigureAwait(false); var allScores = new List { userScore }; // Other scores could have arrived between score submission and entering the results screen. Ensure the local player score position is up to date. @@ -113,98 +136,157 @@ namespace osu.Game.Screens.OnlinePlay.Playlists setPositions(lowerScores, userScore.Position.Value, 1); } - Schedule(() => - { - PerformSuccessCallback(scoresCallback, allScores); - hideLoadingSpinners(); - }); - }; - - // On failure, fallback to a normal index. - userScoreReq.Failure += _ => API.Queue(createIndexRequest(scoresCallback)); - - return userScoreReq; + return await transformScores(allScores).ConfigureAwait(false); + } + catch + { + return await fetchScoresAround().ConfigureAwait(false); + } } - protected override APIRequest? FetchNextPage(int direction, Action> scoresCallback) + protected override async Task FetchNextPage(int direction) { Debug.Assert(direction == 1 || direction == -1); MultiplayerScores? pivot = direction == -1 ? higherScores : lowerScores; - if (pivot?.Cursor == null) - return null; + return []; - if (pivot == higherScores) - LeftSpinner.Show(); - else - RightSpinner.Show(); + Schedule(() => + { + if (pivot == higherScores) + LeftSpinner.Show(); + else + RightSpinner.Show(); + }); - return createIndexRequest(scoresCallback, pivot); + return await fetchScoresAround(pivot).ConfigureAwait(false); } /// /// Creates a with an optional score pivot. /// /// Does not queue the request. - /// The callback to perform with the resulting scores. /// An optional score pivot to retrieve scores around. Can be null to retrieve scores from the highest score. - /// The indexing . - private APIRequest createIndexRequest(Action> scoresCallback, MultiplayerScores? pivot = null) + private async Task fetchScoresAround(MultiplayerScores? pivot = null) { + var requestTaskSource = new TaskCompletionSource(); var indexReq = pivot != null ? new IndexPlaylistScoresRequest(RoomId, PlaylistItem.ID, pivot.Cursor, pivot.Params) : new IndexPlaylistScoresRequest(RoomId, PlaylistItem.ID); + indexReq.Success += requestTaskSource.SetResult; + indexReq.Failure += requestTaskSource.SetException; + API.Queue(indexReq); - indexReq.Success += r => + try { + var index = await requestTaskSource.Task.ConfigureAwait(false); + if (pivot == lowerScores) { - lowerScores = r; - setPositions(r, pivot, 1); + lowerScores = index; + setPositions(index, pivot, 1); } else { - higherScores = r; - setPositions(r, pivot, -1); + higherScores = index; + setPositions(index, pivot, -1); + + // when paginating the results, it's possible for the user's score to naturally fall down the rankings. + // unmitigated, this can cause scores at the very top of the rankings to have zero or negative positions + // because the positions are counted backwards from the user's score, which has increased in this case during pagination. + // if this happens, just give the top score the first position. + // note that this isn't 100% correct, but it *is* however the most reliable way to mask the problem. + int smallestPosition = index.Scores.Min(s => s.Position ?? 1); + + if (smallestPosition < 1) + { + int offset = 1 - smallestPosition; + + foreach (var scorePanel in ScorePanelList.GetScorePanels()) + scorePanel.ScorePosition.Value += offset; + + foreach (var score in index.Scores) + score.Position += offset; + } } - Schedule(() => - { - PerformSuccessCallback(scoresCallback, r.Scores, r); - hideLoadingSpinners(r); - }); - }; - - indexReq.Failure += _ => hideLoadingSpinners(pivot); - - return indexReq; + return await transformScores(index.Scores).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log($"Failed to fetch scores (room: {RoomId}, item: {PlaylistItem.ID}): {ex}"); + return []; + } } /// - /// Transforms returned into s, ensure the is put into a sane state, and invokes a given success callback. + /// Transforms returned into s. /// - /// The callback to invoke with the final s. /// The s that were retrieved from s. - /// An optional pivot around which the scores were retrieved. - protected virtual ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + private async Task transformScores(List scores) { - var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, PlaylistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); + int[] allBeatmapIds = scores.Select(s => s.BeatmapId).Distinct().ToArray(); + BeatmapInfo[] localBeatmaps = allBeatmapIds.Select(id => beatmapManager.QueryBeatmap(b => b.OnlineID == id)) + .Where(b => b != null) + .ToArray()!; - // Invoke callback to add the scores. Exclude the score provided to this screen since it's added already. - callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); + int[] missingBeatmapIds = allBeatmapIds.Except(localBeatmaps.Select(b => b.OnlineID)).ToArray(); + APIBeatmap[] onlineBeatmaps = (await beatmapLookupCache.GetBeatmapsAsync(missingBeatmapIds).ConfigureAwait(false)).Where(b => b != null).ToArray()!; - return scoreInfos; + Dictionary beatmapsById = new Dictionary(); + + foreach (var beatmap in localBeatmaps) + beatmapsById[beatmap.OnlineID] = beatmap; + + foreach (var beatmap in onlineBeatmaps) + { + // Minimal data required to get various components in this screen to display correctly. + beatmapsById[beatmap.OnlineID] = new BeatmapInfo + { + Difficulty = new BeatmapDifficulty(beatmap.Difficulty), + Metadata = + { + Artist = beatmap.Metadata.Artist, + Title = beatmap.Metadata.Title, + Author = new RealmUser + { + Username = beatmap.Metadata.Author.Username, + OnlineID = beatmap.Metadata.Author.OnlineID, + } + }, + DifficultyName = beatmap.DifficultyName, + StarRating = beatmap.StarRating, + Length = beatmap.Length, + BPM = beatmap.BPM + }; + } + + // Validate that we have all beatmaps we need. + foreach (int id in allBeatmapIds) + { + if (!beatmapsById.ContainsKey(id)) + { + Logger.Log($"Failed to fetch beatmap {id} to display scores for playlist item {PlaylistItem.ID}"); + beatmapsById[id] = Beatmap.Value.BeatmapInfo; + } + } + + // Exclude the score provided to this screen since it's added already. + return scores + .Where(s => s.ID != Score?.OnlineID) + .Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, beatmapsById[s.BeatmapId])) + .OrderByTotalScore() + .ToArray(); } - private void hideLoadingSpinners(MultiplayerScores? pivot = null) + protected override void OnScoresAdded(ScoreInfo[] scores) { - CentreSpinner.Hide(); + base.OnScoresAdded(scores); - if (pivot == lowerScores) - RightSpinner.Hide(); - else if (pivot == higherScores) - LeftSpinner.Hide(); + CentreSpinner.Hide(); + RightSpinner.Hide(); + LeftSpinner.Hide(); } /// @@ -213,7 +295,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// The to set positions on. /// The pivot. /// The amount to increment the pivot position by for each in . - private void setPositions(MultiplayerScores scores, MultiplayerScores? pivot, int increment) + private static void setPositions(MultiplayerScores scores, MultiplayerScores? pivot, int increment) => setPositions(scores, pivot?.Scores[^1].Position ?? 0, increment); /// @@ -222,7 +304,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// The to set positions on. /// The pivot position. /// The amount to increment the pivot position by for each in . - private void setPositions(MultiplayerScores scores, int pivotPosition, int increment) + private static void setPositions(MultiplayerScores scores, int pivotPosition, int increment) { foreach (var s in scores.Scores) { @@ -231,6 +313,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } } + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(itemBeatmap); + private partial class PanelListLoadingSpinner : LoadingSpinner { private readonly ScorePanelList list; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs index 05c03a4b28..4b7ffe42ea 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.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; -using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Scoring; @@ -29,13 +28,26 @@ namespace osu.Game.Screens.OnlinePlay.Playlists this.scoreId = scoreId; } + protected override Task FetchScores() + { + // Don't attempt to index scores if the given score has an invalid online ID. + // This can happen if the score failed to submit but is otherwise in a presentable state. + return scoreId <= 0 ? Task.FromResult([]) : base.FetchScores(); + } + + protected override Task FetchNextPage(int direction) + { + // Don't attempt to index scores if the given score has an invalid online ID. + // This can happen if the score failed to submit but is otherwise in a presentable state. + return scoreId <= 0 ? Task.FromResult([]) : base.FetchNextPage(direction); + } + protected override APIRequest CreateScoreRequest() => new ShowPlaylistScoreRequest(RoomId, PlaylistItem.ID, scoreId); - protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + protected override void OnScoresAdded(ScoreInfo[] scores) { - var scoreInfos = base.PerformSuccessCallback(callback, scores, pivot); - Schedule(() => SelectedScore.Value ??= scoreInfos.SingleOrDefault(s => s.OnlineID == scoreId)); - return scoreInfos; + base.OnScoresAdded(scores); + SelectedScore.Value ??= scores.SingleOrDefault(s => s.OnlineID == scoreId); } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs index 5b20496dba..866b094178 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -25,17 +23,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override APIRequest CreateScoreRequest() => new ShowPlaylistUserScoreRequest(RoomId, PlaylistItem.ID, userId); - protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + protected override void OnScoresAdded(ScoreInfo[] scores) { - var scoreInfos = base.PerformSuccessCallback(callback, scores, pivot); + base.OnScoresAdded(scores); - Schedule(() => - { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value ??= scoreInfos.FirstOrDefault(s => s.UserID == userId) ?? scoreInfos.FirstOrDefault(); - }); - - return scoreInfos; + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value ??= scores.FirstOrDefault(s => s.UserID == userId) ?? scores.FirstOrDefault(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsBeatmapAvailabilityTracker.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsBeatmapAvailabilityTracker.cs new file mode 100644 index 0000000000..37d3a3f2d4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsBeatmapAvailabilityTracker.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class PlaylistsBeatmapAvailabilityTracker : OnlinePlayBeatmapAvailabilityTracker + { + public new Bindable PlaylistItem => base.PlaylistItem; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs index d66b4f844c..cc4065a82b 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs @@ -1,19 +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.ComponentModel; +using System.Diagnostics; 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.API.Requests; 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; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -59,6 +60,29 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return criteria; } + protected override void JoinInternal(Room room, string? password, Action onSuccess, Action onFailure) + { + var joinRoomRequest = new JoinRoomRequest(room, password); + + joinRoomRequest.Success += r => onSuccess(r); + joinRoomRequest.Failure += exception => + { + if (exception is not OperationCanceledException) + onFailure($"Failed to open playlist. {exception.Message}", exception); + }; + + api.Queue(joinRoomRequest); + } + + public override void Close(Room room) + { + Debug.Assert(room.RoomID != null); + + var request = new ClosePlaylistRequest(room.RoomID.Value); + request.Success += RefreshRooms; + api.Queue(request); + } + protected override OsuButton CreateNewRoomButton() => new CreatePlaylistsRoomButton(); protected override Room CreateNewRoom() @@ -70,9 +94,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }; } - protected override RoomSubScreen CreateRoomSubScreen(Room room) => new PlaylistsRoomSubScreen(room); - - protected override ListingPollingComponent CreatePollingComponent() => new ListingPollingComponent(); + protected override OnlinePlaySubScreen CreateRoomSubScreen(Room room) => new PlaylistsRoomSubScreen(room); private enum PlaylistsCategory { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index b82c2404ab..69a1e3b763 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.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.OnlinePlay.Playlists @@ -21,11 +22,18 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public Action? Exited; + [Cached(typeof(IGameplayLeaderboardProvider))] + private readonly PlaylistsGameplayLeaderboardProvider leaderboardProvider; + 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) + public PlaylistsPlayer(Room room, PlaylistItem playlistItem) + : base(room, playlistItem, new PlayerConfiguration + { + ShowLeaderboard = true, + }) { + leaderboardProvider = new PlaylistsGameplayLeaderboardProvider(room, playlistItem); } [BackgroundDependencyLoader] @@ -41,6 +49,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists var requiredLocalMods = PlaylistItem.RequiredMods.Select(m => m.ToMod(GameplayState.Ruleset)); if (!requiredLocalMods.All(m => Mods.Value.Any(m.Equals))) throw new InvalidOperationException("Current Mods do not match PlaylistItem's RequiredMods"); + + LoadComponentAsync(leaderboardProvider, AddInternal); } public override bool OnExiting(ScreenExitEvent e) @@ -59,7 +69,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return new PlaylistItemScoreResultsScreen(score, Room.RoomID.Value, PlaylistItem) { AllowRetry = true, - ShowUserStatistics = true, + IsLocalPlay = true, }; } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs index 6089b4734e..f9b1edcd59 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; using osuTK; namespace osu.Game.Screens.OnlinePlay.Playlists @@ -99,7 +98,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists if (room.Host?.Id == api.LocalUser.Value.Id) { - if (deletionGracePeriodRemaining > TimeSpan.Zero && room.Status is not RoomStatusEnded) + if (deletionGracePeriodRemaining > TimeSpan.Zero && !room.HasEnded) { closeButton.FadeIn(); using (BeginDelayedSequence(deletionGracePeriodRemaining.Value.TotalMilliseconds)) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.cs new file mode 100644 index 0000000000..1f0f92aea2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFreestyleSelect.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.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class PlaylistsRoomFreestyleSelect : OnlinePlayFreestyleSelect + { + public new readonly Bindable Beatmap = new Bindable(); + public new readonly Bindable Ruleset = new Bindable(); + + public PlaylistsRoomFreestyleSelect(Room room, PlaylistItem item) + : base(room, item) + { + } + + protected override bool OnStart() + { + if (!base.OnStart()) + return false; + + Beatmap.Value = base.Beatmap.Value.BeatmapInfo; + Ruleset.Value = base.Ruleset.Value; + + this.Exit(); + return true; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomPanel.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomPanel.cs new file mode 100644 index 0000000000..d6c0f4dcbc --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomPanel.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge.Components; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + /// + /// A to be displayed in a playlists lobby. + /// + public partial class PlaylistsRoomPanel : RoomPanel + { + public new required Bindable SelectedItem + { + get => selectedItem.Current; + set => selectedItem.Current = value; + } + + private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); + + public PlaylistsRoomPanel(Room room) + : base(room) + { + base.SelectedItem.BindTo(SelectedItem); + } + + protected override UpdateableBeatmapBackgroundSprite CreateBackground() => base.CreateBackground().With(d => + { + d.BackgroundLoadDelay = 0; + }); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 88af161cc8..9c0363f40e 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -75,9 +75,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private PurpleRoundedButton editPlaylistButton = null!; - [Resolved] - private IRoomManager? manager { get; set; } - [Resolved] private IAPIProvider api { get; set; } = null!; @@ -440,7 +437,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists if (!ApplyButton.Enabled.Value) return; - hideError(); + ErrorText.FadeOut(50); room.Name = NameField.Text; room.Availability = AvailabilityPicker.Current.Value; @@ -449,13 +446,13 @@ namespace osu.Game.Screens.OnlinePlay.Playlists room.Duration = DurationField.Current.Value; loadingLayer.Show(); - manager?.CreateRoom(room, onSuccess, onError); + + var req = new CreateRoomRequest(room); + req.Success += _ => loadingLayer.Hide(); + req.Failure += e => onError(req.Response?.Error ?? e.Message); + api.Queue(req); } - private void hideError() => ErrorText.FadeOut(50); - - private void onSuccess(Room room) => loadingLayer.Hide(); - private void onError(string text) { // see https://github.com/ppy/osu-web/blob/2c97aaeb64fb4ed97c747d8383a35b30f57428c7/app/Models/Multiplayer/PlaylistItem.php#L48. diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 9573155f5a..fdda6f6c85 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -2,56 +2,156 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.ComponentModel; 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.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Audio; +using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; +using osu.Game.Utils; using osuTK; using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Playlists { - public partial class PlaylistsRoomSubScreen : RoomSubScreen + public partial class PlaylistsRoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner { + /// + /// Footer height. + /// + private const float footer_height = 50; + + /// + /// Padding between content and footer. + /// + private const float footer_padding = 30; + + /// + /// Internal padding of the content. + /// + private const float content_padding = 20; + + /// + /// Padding between columns of the content. + /// + private const float column_padding = 10; + + /// + /// Padding between rows of the content. + /// + private const float row_padding = 10; + public override string Title { get; } public override string ShortTitle => "playlist"; - private readonly IBindable isIdle = new BindableBool(); + public override bool? ApplyModTrackAdjustments => true; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + /// + /// Whether the user has confirmed they want to exit this screen in the presence of unsaved changes. + /// + protected bool ExitConfirmed { get; private set; } [Resolved] private IAPIProvider api { get; set; } = null!; - [Resolved(CanBeNull = true)] + [Resolved] + private AudioManager audio { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private MusicController music { get; set; } = null!; + + [Resolved] private IdleTracker? idleTracker { get; set; } + [Resolved] + private OnlinePlayScreen? parentScreen { get; set; } + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly PlaylistsBeatmapAvailabilityTracker beatmapAvailabilityTracker; + + protected readonly Bindable SelectedItem = new Bindable(); + protected readonly Bindable UserBeatmap = new Bindable(); + protected readonly Bindable UserRuleset = new Bindable(); + protected readonly Bindable> UserMods = new Bindable>(Array.Empty()); + + private readonly IBindable isIdle = new BindableBool(); + private readonly Room room; + + private Drawable roomContent = null!; + private PlaylistsRoomUpdater roomUpdater = null!; + private PlaylistsRoomSettingsOverlay settingsOverlay = null!; + private MatchLeaderboard leaderboard = null!; - private SelectionPollingComponent selectionPollingComponent = null!; private FillFlowContainer progressSection = null!; private DrawableRoomPlaylist drawablePlaylist = null!; + private FillFlowContainer userModsSection = null!; + private RoomModSelectOverlay userModsSelectOverlay = null!; + + private FillFlowContainer userStyleSection = null!; + private Container userStyleDisplayContainer = null!; + + private Sample? sampleStart; + private IDisposable? userModsSelectOverlayRegistration; + public PlaylistsRoomSubScreen(Room room) - : base(room, false) // Editing is temporarily not allowed. { + this.room = room; + Title = room.RoomID == null ? "New playlist" : room.Name; Activity.Value = new UserActivity.InLobby(room); + + Padding = new MarginPadding { Top = Header.HEIGHT }; + + beatmapAvailabilityTracker = new PlaylistsBeatmapAvailabilityTracker + { + PlaylistItem = { BindTarget = SelectedItem } + }; } [BackgroundDependencyLoader] @@ -60,21 +160,325 @@ namespace osu.Game.Screens.OnlinePlay.Playlists if (idleTracker != null) isIdle.BindTo(idleTracker.IsIdle); - AddInternal(selectionPollingComponent = new SelectionPollingComponent(Room)); + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + roomUpdater = new PlaylistsRoomUpdater(room), + beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Bottom = footer_height + footer_padding + }, + Children = new[] + { + roomContent = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, row_padding), + }, + Content = new[] + { + new Drawable[] + { + new PlaylistsRoomPanel(room) + { + SelectedItem = SelectedItem + } + }, + null, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(content_padding), + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + }, + Content = new[] + { + new Drawable?[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new OverlinedPlaylistHeader(room), + }, + new Drawable[] + { + drawablePlaylist = new DrawableRoomPlaylist + { + RelativeSizeAxes = Axes.Both, + SelectedItem = { BindTarget = SelectedItem }, + AllowSelection = true, + AllowShowingResults = true, + RequestResults = showResults + } + }, + new Drawable[] + { + new AddPlaylistToCollectionButton(room) + { + Margin = new MarginPadding { Top = 5 }, + RelativeSizeAxes = Axes.X, + Size = new Vector2(1, 40) + } + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + userModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = row_padding }, + 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, + Height = 30, + Text = "Select", + Action = showUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + } + } + } + } + } + }, + new Drawable[] + { + userStyleSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = row_padding }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Difficulty"), + userStyleDisplayContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + new Drawable[] + { + progressSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = row_padding }, + Alpha = 0, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OverlinedHeader("Progress"), + new RoomLocalUserInfo(room), + } + } + }, + new Drawable[] + { + new OverlinedHeader("Leaderboard") + }, + new Drawable[] + { + leaderboard = new MatchLeaderboard(room) + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new OverlinedHeader("Chat") + }, + new Drawable[] + { + new MatchChatDisplay(room) + { + RelativeSizeAxes = Axes.Both + } + } + } + } + } + } + } + } + } + } + } + }, + settingsOverlay = new PlaylistsRoomSettingsOverlay(room) + { + EditPlaylist = () => + { + if (this.IsCurrentScreen()) + this.Push(new PlaylistsSongSelect(room)); + } + } + } + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = footer_height, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d") // Temporary. + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Child = new PlaylistsRoomFooter(room) + { + OnStart = startPlay, + OnClose = closePlaylist + } + } + } + } + } + } + }; + + LoadComponent(userModsSelectOverlay = new RoomModSelectOverlay + { + SelectedItem = { BindTarget = SelectedItem }, + SelectedMods = { BindTarget = UserMods }, + Beatmap = { BindTarget = Beatmap }, + IsValidMod = _ => false + }); } protected override void LoadComplete() { base.LoadComplete(); + userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); + + room.PropertyChanged += onRoomPropertyChanged; + isIdle.BindValueChanged(_ => updatePollingRate(), true); + beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateGameplayState()); + + SelectedItem.BindValueChanged(onSelectedItemChanged); + UserBeatmap.BindValueChanged(_ => updateGameplayState()); + UserMods.BindValueChanged(_ => updateGameplayState()); + UserRuleset.BindValueChanged(_ => + { + // The user mod selection overlay is separate from the beatmap/ruleset style selection screen, + // and so the validity of mods has to be confirmed separately after the ruleset is changed. + validateUserMods(); + updateGameplayState(); + }); - Room.PropertyChanged += onRoomPropertyChanged; updateSetupState(); - updateRoomMaxAttempts(); - updateRoomPlaylist(); + updateUserScore(); + updateGameplayState(); } + /// + /// Responds to changes of the 's properties. + /// + /// The that changed. + /// Describes the property that changed. private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { switch (e.PropertyName) @@ -83,230 +487,402 @@ namespace osu.Game.Screens.OnlinePlay.Playlists updateSetupState(); break; - case nameof(Room.MaxAttempts): - updateRoomMaxAttempts(); - break; - - case nameof(Room.Playlist): - updateRoomPlaylist(); + case nameof(Room.UserScore): + updateUserScore(); break; } } + /// + /// Responds to changes in to adjust the visibility of the settings and main content. + /// Only the settings overlay is visible while the room isn't created, and only the main content is visible after creation. + /// private void updateSetupState() { - if (Room.RoomID != null) + if (room.RoomID == null) { - // 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 = Room.Playlist.FirstOrDefault()); + // 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. + roomContent.Hide(); + settingsOverlay.Show(); + } + else + { + roomContent.Show(); + settingsOverlay.Hide(); + + // Scheduled because room properties are updated in arbitrary order. + Schedule(() => + { + progressSection.Alpha = room.MaxAttempts != null ? 1 : 0; + drawablePlaylist.Items.ReplaceRange(0, drawablePlaylist.Items.Count, room.Playlist); + + updateUserScore(); + + // Select an initial item for the user to help them get into a playable state quicker. + SelectedItem.Value = room.Playlist.FirstOrDefault(); + }); } } - private void updateRoomMaxAttempts() - => progressSection.Alpha = Room.MaxAttempts != null ? 1 : 0; - - private void updateRoomPlaylist() - => drawablePlaylist.Items.ReplaceRange(0, drawablePlaylist.Items.Count, Room.Playlist); - - protected override Drawable CreateMainContent() => new Container + /// + /// Responds to changes in to mark playlist items as completed. + /// + private void updateUserScore() { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new OsuContextMenuContainer + if (room.UserScore == null) + return; + + if (drawablePlaylist.Items.Count == 0) + return; + + foreach (var item in room.UserScore.PlaylistItemAttempts) { - RelativeSizeAxes = Axes.Both, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable?[] - { - // Playlist items column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Content = new[] - { - new Drawable[] { new OverlinedPlaylistHeader(Room), }, - new Drawable[] - { - drawablePlaylist = new DrawableRoomPlaylist - { - RelativeSizeAxes = Axes.Both, - SelectedItem = { BindTarget = SelectedItem }, - AllowSelection = true, - AllowShowingResults = true, - RequestResults = item => - { - Debug.Assert(Room.RoomID != null); - ParentScreen?.Push(new PlaylistItemUserBestResultsScreen(Room.RoomID.Value, item, api.LocalUser.Value.Id)); - } - } - }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - // Spacer - null, - // Middle column (mods and leaderboard) - 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[] - { - progressSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OverlinedHeader("Progress"), - new RoomLocalUserInfo(Room), - } - }, - }, - new Drawable[] - { - new OverlinedHeader("Leaderboard") - }, - new Drawable[] { leaderboard = new MatchLeaderboard(Room) { RelativeSizeAxes = Axes.Both }, }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - // Spacer - null, - // Main right column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - }, - }, - } + if (item.Passed) + drawablePlaylist.Items.Single(i => i.ID == item.PlaylistItemID).MarkCompleted(); } - }; - - protected override Drawable CreateFooter() => new PlaylistsRoomFooter(Room) - { - OnStart = StartPlay, - OnClose = closePlaylist, - }; - - protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new PlaylistsRoomSettingsOverlay(room) - { - EditPlaylist = () => - { - if (this.IsCurrentScreen()) - this.Push(new PlaylistsSongSelect(Room)); - }, - }; + } + /// + /// Adjusts the rate at which the is updated. + /// private void updatePollingRate() { - selectionPollingComponent.TimeBetweenPolls.Value = isIdle.Value ? 30000 : 5000; - Logger.Log($"Polling adjusted (selection: {selectionPollingComponent.TimeBetweenPolls.Value})"); + roomUpdater.TimeBetweenPolls.Value = isIdle.Value ? 30000 : 5000; + Logger.Log($"Polling adjusted (selection: {roomUpdater.TimeBetweenPolls.Value})"); } - private void closePlaylist() + /// + /// Responds to changes in to validate the user style and update the global gameplay state. + /// + private void onSelectedItemChanged(ValueChangedEvent item) { - DialogOverlay?.Push(new ClosePlaylistDialog(Room, () => + if (item.NewValue == null) + return; + + // Always resetting the user beatmap style when a new item is selected is most intuitive. + UserBeatmap.Value = null; + + if (item.NewValue.Freestyle) { - var request = new ClosePlaylistRequest(Room.RoomID!.Value); - request.Success += () => + // If freestyle is active, attempt to preserve the user ruleset style but only if the online item is from the osu! ruleset + // (i.e. the beatmap is generally always convertible to the current ruleset, excluding custom rulesets). + if (item.NewValue.RulesetID > 0) + UserRuleset.Value = null; + } + else + UserRuleset.Value = null; + + validateUserMods(); + updateGameplayState(); + } + + /// + /// Validates the user mod style against the selected item and ruleset style. + /// + private void validateUserMods() + { + if (SelectedItem.Value == null) + return; + + PlaylistItem item = SelectedItem.Value; + RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; + Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); + + UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); + } + + /// + /// Updates the global states in preparation for a new gameplay session. + /// + private void updateGameplayState() + { + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + PlaylistItem item = SelectedItem.Value; + + IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; + RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); + Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); + + // Update global gameplay state to correspond to the new selection. + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", gameplayBeatmap.OnlineID); + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = gameplayRuleset; + Mods.Value = UserMods.Value.Concat(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToArray(); + + // Update UI elements to reflect the new selection. + bool freemods = item.Freestyle || allowedMods.Length > 0; + bool freestyle = item.Freestyle; + + if (freemods) + { + userModsSection.Show(); + userModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); + } + else + { + userModsSection.Hide(); + userModsSelectOverlay.Hide(); + userModsSelectOverlay.IsValidMod = _ => false; + } + + if (freestyle) + { + userStyleSection.Show(); + + PlaylistItem gameplayItem = item.With(ruleset: gameplayRuleset.OnlineID, beatmap: new Optional(gameplayBeatmap)); + DrawableRoomPlaylistItem? currentDisplay = userStyleDisplayContainer.SingleOrDefault(); + + if (!gameplayItem.Equals(currentDisplay?.Item)) { - Room.Status = new RoomStatusEnded(); - Room.EndDate = DateTimeOffset.UtcNow; - }; - API.Queue(request); + userStyleDisplayContainer.Child = currentDisplay = new DrawableRoomPlaylistItem(gameplayItem, true) + { + AllowReordering = false, + RequestEdit = _ => showUserStyleSelect() + }; + } + + currentDisplay.AllowEditing = localBeatmap != null; + } + else + userStyleSection.Hide(); + } + + /// + /// Pushes a to start gameplay with the current selection. + /// + private void startPlay() + { + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + PlaylistItem item = SelectedItem.Value; + + // Required for validation inside the player. + RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; + IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; + PlaylistItem gameplayItem = item.With(ruleset: gameplayRuleset.OnlineID, beatmap: new Optional(gameplayBeatmap)); + + sampleStart?.Play(); + + // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). + var targetScreen = (Screen?)parentScreen ?? this; + targetScreen.Push(new PlayerLoader(() => new PlaylistsPlayer(room, gameplayItem) + { + Exited = () => leaderboard.RefetchScores() })); } - protected override Screen CreateGameplayScreen(PlaylistItem selectedItem) + /// + /// Shows the user mod selection. + /// + private void showUserModSelect() { - return new PlayerLoader(() => new PlaylistsPlayer(Room, selectedItem) + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + userModsSelectOverlay.Show(); + } + + /// + /// Shows the user style selection. + /// + private void showUserStyleSelect() + { + if (!this.IsCurrentScreen() || SelectedItem.Value == null) + return; + + this.Push(new PlaylistsRoomFreestyleSelect(room, SelectedItem.Value) { - Exited = () => leaderboard.RefetchScores() + Beatmap = { BindTarget = UserBeatmap }, + Ruleset = { BindTarget = UserRuleset } }); } + /// + /// Shows the results screen for a playlist item. + /// + private void showResults(PlaylistItem item) + { + if (!this.IsCurrentScreen()) + return; + + Debug.Assert(room.RoomID != null); + + // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). + var targetScreen = (Screen?)parentScreen ?? this; + targetScreen.Push(new PlaylistItemUserBestResultsScreen(room.RoomID.Value, item, api.LocalUser.Value.OnlineID)); + } + + /// + /// May be invoked by the owner of the room to permanently close the room ahead of its intended end date. + /// + private void closePlaylist() + { + dialogOverlay?.Push(new ClosePlaylistDialog(room, () => + { + var request = new ClosePlaylistRequest(room.RoomID!.Value); + request.Success += () => room.EndDate = DateTimeOffset.UtcNow; + api.Queue(request); + })); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + beginHandlingTrack(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + onLeaving(); + base.OnSuspending(e); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + beginHandlingTrack(); + + // Required to update beatmap/ruleset when resuming from style selection. + updateGameplayState(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (!ensureExitConfirmed()) + return true; + + if (room.RoomID != null) + api.Queue(new PartRoomRequest(room)); + + onLeaving(); + return base.OnExiting(e); + } + + public override bool OnBackButton() + { + if (room.RoomID == null) + { + if (!ensureExitConfirmed()) + return true; + + 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(); + } + + private void onLeaving() + { + // Must hide this overlay because it is added to a global container. + userModsSelectOverlay.Hide(); + + endHandlingTrack(); + } + + /// + /// Handles changes in the track to keep it looping while active. + /// + private void beginHandlingTrack() + { + Beatmap.BindValueChanged(applyLoopingToTrack, true); + } + + /// + /// Stops looping the current track and stops handling further changes to the track. + /// + private void endHandlingTrack() + { + Beatmap.ValueChanged -= applyLoopingToTrack; + Beatmap.Value.Track.Looping = false; + + previewTrackManager.StopAnyPlaying(this); + } + + /// + /// Invoked on changes to the beatmap to loop the track. See: . + /// + /// The beatmap change event. + private void applyLoopingToTrack(ValueChangedEvent beatmap) + { + if (!this.IsCurrentScreen()) + return; + + beatmap.NewValue.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + + /// + /// Prompts the user to discard unsaved changes to the room before exiting. + /// + /// true if the user has confirmed they want to exit. + private bool ensureExitConfirmed() + { + if (ExitConfirmed) + return true; + + if (api.State.Value != APIState.Online) + return true; + + bool hasUnsavedChanges = room.RoomID == null && room.Playlist.Count > 0; + + if (dialogOverlay == null || !hasUnsavedChanges) + return true; + + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) + { + discardChangesDialog.Flash(); + return false; + } + + dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + { + ExitConfirmed = true; + settingsOverlay.Hide(); + this.Exit(); + })); + + return false; + } + + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagateNonPositionalInputSubTree => base.PropagateNonPositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + protected override BackgroundScreen CreateBackground() => new RoomBackgroundScreen(room.Playlist.FirstOrDefault()) + { + SelectedItem = { BindTarget = SelectedItem } + }; + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - Room.PropertyChanged -= onRoomPropertyChanged; + + userModsSelectOverlayRegistration?.Dispose(); + room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomUpdater.cs similarity index 65% rename from osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs rename to osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomUpdater.cs index 7cee8b3546..f68703750a 100644 --- a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomUpdater.cs @@ -2,18 +2,24 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Game.Online; +using osu.Game.Online.API; using osu.Game.Online.Rooms; -namespace osu.Game.Screens.OnlinePlay.Components +namespace osu.Game.Screens.OnlinePlay.Playlists { /// - /// A that polls for the currently-selected room. + /// A that polls for and updates a room. /// - public partial class SelectionPollingComponent : RoomPollingComponent + public partial class PlaylistsRoomUpdater : PollingComponent { + [Resolved] + private IAPIProvider api { get; set; } = null!; + private readonly Room room; - public SelectionPollingComponent(Room room) + public PlaylistsRoomUpdater(Room room) { this.room = room; } @@ -22,27 +28,26 @@ namespace osu.Game.Screens.OnlinePlay.Components protected override Task Poll() { - if (!API.IsLoggedIn) + if (!api.IsLoggedIn) return base.Poll(); if (room.RoomID == null) return base.Poll(); - var tcs = new TaskCompletionSource(); - lastPollRequest?.Cancel(); + var tcs = new TaskCompletionSource(); var req = new GetRoomRequest(room.RoomID.Value); req.Success += result => { - RoomManager.AddOrUpdateRoom(result); + room.CopyFrom(result); tcs.SetResult(true); }; req.Failure += _ => tcs.SetResult(false); - API.Queue(req); + api.Queue(req); lastPollRequest = req; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 23824b6a73..84446ed0cf 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -39,7 +39,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1, RulesetID = Ruleset.Value.OnlineID, RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() + AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), + Freestyle = Freestyle.Value }; } } diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index ab66241a77..1307be6494 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -47,6 +47,8 @@ namespace osu.Game.Screens public virtual bool HideMenuCursorOnNonMouseInput => false; + public virtual bool RequiresPortraitOrientation => false; + /// /// The initial overlay activation mode to use when this screen is entered for the first time. /// @@ -81,7 +83,7 @@ namespace osu.Game.Screens /// protected readonly Bindable Activity = new Bindable(); - IBindable IOsuScreen.Activity => Activity; + Bindable IOsuScreen.Activity => Activity; /// /// Whether to disallow changes to game-wise Beatmap/Ruleset bindables for this screen (and all children). @@ -115,6 +117,8 @@ namespace osu.Game.Screens internal void CreateLeasedDependencies(IReadOnlyDependencyContainer dependencies) => createDependencies(dependencies); + internal void LoadComponentsAgainstScreenDependencies(IEnumerable components) => LoadComponents(components); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { if (screenDependencies == null) diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index 66aa3d9cc0..23264c4518 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -4,7 +4,6 @@ #nullable disable using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -20,6 +19,7 @@ using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; using osuTK; +using CommonStrings = osu.Game.Localisation.CommonStrings; namespace osu.Game.Screens.Play { @@ -55,7 +55,7 @@ namespace osu.Game.Screens.Play this.mods.BindTo(mods); } - private IBindable starDifficulty; + private IBindable starDifficulty; private FillFlowContainer versionFlow; private StarRatingDisplay starRatingDisplay; @@ -166,7 +166,7 @@ namespace osu.Game.Screens.Play }, new Drawable[] { - new MetadataLineLabel("Mapper"), + new MetadataLineLabel(CommonStrings.Mapper), new MetadataLineInfo(metadata.Author.Username) } } @@ -191,25 +191,15 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - if (starDifficulty.Value != null) + starDifficulty.BindValueChanged(d => { - starRatingDisplay.Current.Value = starDifficulty.Value.Value; - starRatingDisplay.Show(); - } - else - starRatingDisplay.Hide(); - - starDifficulty.ValueChanged += d => - { - Debug.Assert(d.NewValue != null); - - starRatingDisplay.Current.Value = d.NewValue.Value; + starRatingDisplay.Current.Value = d.NewValue; versionFlow.AutoSizeDuration = 300; versionFlow.AutoSizeEasing = Easing.OutQuint; starRatingDisplay.FadeIn(300, Easing.InQuint); - }; + }, true); } private partial class MetadataLineLabel : OsuSpriteText diff --git a/osu.Game/Screens/Play/Break/BreakInfo.cs b/osu.Game/Screens/Play/Break/BreakInfo.cs index ef453405b5..28c38dce2b 100644 --- a/osu.Game/Screens/Play/Break/BreakInfo.cs +++ b/osu.Game/Screens/Play/Break/BreakInfo.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.Localisation; using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osuTK; @@ -32,7 +34,7 @@ namespace osu.Game.Screens.Play.Break { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "current progress".ToUpperInvariant(), + Text = BreakInfoStrings.CurrentProgressTitle.ToUpper(), Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15), }, new FillFlowContainer @@ -46,7 +48,7 @@ namespace osu.Game.Screens.Play.Break AccuracyDisplay = new PercentageBreakInfoLine(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy), // See https://github.com/ppy/osu/discussions/15185 // RankDisplay = new BreakInfoLine("Rank"), - GradeDisplay = new BreakInfoLine("Grade"), + GradeDisplay = new BreakInfoLine(BreakInfoStrings.ShowInfoGrade), }, } }, diff --git a/osu.Game/Screens/Play/Break/LetterboxOverlay.cs b/osu.Game/Screens/Play/Break/LetterboxOverlay.cs deleted file mode 100644 index 9308a02b07..0000000000 --- a/osu.Game/Screens/Play/Break/LetterboxOverlay.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osuTK.Graphics; - -namespace osu.Game.Screens.Play.Break -{ - public partial class LetterboxOverlay : CompositeDrawable - { - private static readonly Color4 transparent_black = new Color4(0, 0, 0, 0); - - public LetterboxOverlay() - { - const int height = 150; - - RelativeSizeAxes = Axes.Both; - InternalChildren = new Drawable[] - { - new Box - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - RelativeSizeAxes = Axes.X, - Height = height, - Colour = ColourInfo.GradientVertical(Color4.Black, transparent_black), - }, - new Box - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = height, - Colour = ColourInfo.GradientVertical(transparent_black, Color4.Black), - } - }; - } - } -} diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 550d29965f..234daece5e 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -35,7 +35,7 @@ namespace osu.Game.Screens.Play public override bool RemoveCompletedTransforms => false; - public BreakTracker BreakTracker { get; init; } = null!; + public required BreakTracker BreakTracker { get; init; } private readonly Container remainingTimeAdjustmentBox; private readonly Container remainingTimeBox; @@ -46,7 +46,7 @@ namespace osu.Game.Screens.Play private readonly IBindable currentPeriod = new Bindable(); - public BreakOverlay(bool letterboxing, ScoreProcessor scoreProcessor) + public BreakOverlay(ScoreProcessor scoreProcessor) { this.scoreProcessor = scoreProcessor; RelativeSizeAxes = Axes.Both; @@ -63,12 +63,6 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new LetterboxOverlay - { - Alpha = letterboxing ? 1 : 0, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, new CircularContainer { Anchor = Anchor.Centre, @@ -165,13 +159,12 @@ namespace osu.Game.Screens.Play if (currentPeriod.Value == null) return; - float timeBoxTargetWidth = (float)Math.Max(0, (remainingTimeForCurrentPeriod - timingPoint.BeatLength / currentPeriod.Value.Value.Duration)); + float timeBoxTargetWidth = (float)Math.Max(0, remainingTimeForCurrentPeriod - timingPoint.BeatLength / currentPeriod.Value.Value.Duration); remainingTimeBox.ResizeWidthTo(timeBoxTargetWidth, timingPoint.BeatLength * 3.5, Easing.OutQuint); } private void updateDisplay(ValueChangedEvent period) { - FinishTransforms(true); Scheduler.CancelDelayedTasks(); if (period.NewValue == null) @@ -186,12 +179,12 @@ namespace osu.Game.Screens.Play remainingTimeAdjustmentBox .ResizeWidthTo(remaining_time_container_max_size, BREAK_FADE_DURATION, Easing.OutQuint) - .Delay(b.Duration - BREAK_FADE_DURATION) + .Delay(b.Duration) .ResizeWidthTo(0); remainingTimeBox.ResizeWidthTo(remainingTimeForCurrentPeriod); - remainingTimeCounter.CountTo(b.Duration).CountTo(0, b.Duration); + remainingTimeCounter.CountTo(b.Duration + BREAK_FADE_DURATION).CountTo(0, b.Duration + BREAK_FADE_DURATION); remainingTimeCounter.MoveToX(-50) .MoveToX(0, BREAK_FADE_DURATION, Easing.OutQuint); @@ -199,7 +192,7 @@ namespace osu.Game.Screens.Play info.MoveToX(50) .MoveToX(0, BREAK_FADE_DURATION, Easing.OutQuint); - using (BeginDelayedSequence(b.Duration - BREAK_FADE_DURATION)) + using (BeginDelayedSequence(b.Duration)) { fadeContainer.FadeOut(BREAK_FADE_DURATION); breakArrows.Hide(BREAK_FADE_DURATION); diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 84d99ea863..a096400fe0 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -69,7 +69,22 @@ namespace osu.Game.Screens.Play protected override void LoadComplete() { - ShowStoryboard.BindValueChanged(_ => initializeStoryboard(true), true); + ShowStoryboard.BindValueChanged(show => + { + initializeStoryboard(true); + + if (drawableStoryboard != null) + { + // Regardless of user dim setting, for the time being we need to ensure storyboards are still updated in the background (even if not displayed). + // If we don't do this, an intensive storyboard will have a lot of catch-up work to do at the start of a break, causing a huge stutter. + // + // This can be reconsidered when https://github.com/ppy/osu-framework/issues/6491 is resolved. + bool alwaysPresent = show.NewValue; + + Content.AlwaysPresent = alwaysPresent; + drawableStoryboard.AlwaysPresent = alwaysPresent; + } + }, true); base.LoadComplete(); } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 255877e0aa..2afdcfaebb 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -39,6 +39,8 @@ namespace osu.Game.Screens.Play /// public double StartTime { get; protected set; } + public double GameplayStartTime { get; protected set; } + public IAdjustableAudioComponent AdjustmentsFromMods { get; } = new AudioAdjustments(); private readonly BindableBool isPaused = new BindableBool(true); diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 2b961278d5..d4c40c78ae 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -22,6 +22,8 @@ using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Utils; namespace osu.Game.Screens.Play { @@ -166,11 +168,6 @@ namespace osu.Game.Screens.Play protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In); - // Don't let mouse down events through the overlay or people can click circles while paused. - protected override bool OnMouseDown(MouseDownEvent e) => true; - - protected override bool OnMouseMove(MouseMoveEvent e) => true; - protected void AddButton(LocalisableString text, Color4 colour, Action? action) { var button = new Button @@ -236,6 +233,14 @@ namespace osu.Game.Screens.Play playInfoText.AddText(GameplayMenuOverlayStrings.SongProgress); playInfoText.AddText($"{progress}%", cp => cp.Font = cp.Font.With(weight: FontWeight.Bold)); } + + if (gameplayState != null) + { + playInfoText.NewLine(); + playInfoText.AddText(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy); + playInfoText.AddText(": "); + playInfoText.AddText(gameplayState!.ScoreProcessor.Accuracy.Value.FormatAccuracy(), cp => cp.Font = cp.Font.With(weight: FontWeight.Bold)); + } } private int? getSongProgress() diff --git a/osu.Game/Screens/Play/GameplayScrollWheelHandling.cs b/osu.Game/Screens/Play/GameplayScrollWheelHandling.cs new file mode 100644 index 0000000000..059d5a0dd4 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayScrollWheelHandling.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Overlays.Volume; + +namespace osu.Game.Screens.Play +{ + /// + /// Primarily handles volume adjustment in gameplay. + /// + /// - If the user has mouse wheel disabled, only allow during break time or when holding alt. Also block scroll from parent handling. + /// - Otherwise always allow, as per implementation. + /// + internal partial class GameplayScrollWheelHandling : GlobalScrollAdjustsVolume + { + private Bindable mouseWheelDisabled = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + mouseWheelDisabled = config.GetBindable(OsuSetting.MouseDisableWheel); + } + + protected override bool OnScroll(ScrollEvent e) + { + // During pause, allow global volume adjust regardless of settings. + if (gameplayClock.IsPaused.Value) + return base.OnScroll(e); + + // Block any parent handling of scroll if the user has asked for it (special case when holding "Alt"). + if (mouseWheelDisabled.Value && !e.AltPressed) + return true; + + return base.OnScroll(e); + } + } +} diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index 478acd7229..80546ef6da 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -62,6 +62,8 @@ namespace osu.Game.Screens.Play /// public bool HasQuit { get; set; } + public bool HasCompleted => HasPassed || HasFailed || HasQuit; + /// /// A bindable tracking the last judgement result applied to any hit object. /// @@ -69,6 +71,11 @@ namespace osu.Game.Screens.Play private readonly Bindable lastJudgementResult = new Bindable(); + /// + /// The local user's playing state (whether actively playing, paused, or not playing due to watching a replay or similar). + /// + public IBindable PlayingState { get; } = new Bindable(); + public GameplayState( IBeatmap beatmap, Ruleset ruleset, @@ -76,7 +83,8 @@ namespace osu.Game.Screens.Play Score? score = null, ScoreProcessor? scoreProcessor = null, HealthProcessor? healthProcessor = null, - Storyboard? storyboard = null) + Storyboard? storyboard = null, + IBindable? localUserPlayingState = null) { Beatmap = beatmap; Ruleset = ruleset; @@ -92,6 +100,9 @@ namespace osu.Game.Screens.Play ScoreProcessor = scoreProcessor ?? ruleset.CreateScoreProcessor(); HealthProcessor = healthProcessor ?? ruleset.CreateHealthProcessor(beatmap.HitObjects[0].StartTime); Storyboard = storyboard ?? new Storyboard(); + + if (localUserPlayingState != null) + PlayingState.BindTo(localUserPlayingState); } /// diff --git a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs index d7fe1f52ff..c4cf52c254 100644 --- a/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonAccuracyCounter.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs index e82e8f4b6f..22d65601cd 100644 --- a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index bd8f17185b..d55bf46f97 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -15,6 +15,7 @@ using osu.Framework.Text; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { @@ -26,6 +27,8 @@ namespace osu.Game.Screens.Play.HUD public IBindable WireframeOpacity { get; } = new BindableFloat(); public Bindable ShowLabel { get; } = new BindableBool(); + public Bindable LabelColour { get; } = new Bindable(Color4.White); + public Bindable TextColour { get; } = new Bindable(Color4.White); public Container NumberContainer { get; private set; } @@ -58,7 +61,6 @@ namespace osu.Game.Screens.Play.HUD labelText = new OsuSpriteText { Alpha = 0, - BypassAutoSizeAxes = Axes.X, Text = label.GetValueOrDefault(), Font = OsuFont.Torus.With(size: 12, weight: FontWeight.Bold), Margin = new MarginPadding { Left = 2.5f }, @@ -72,13 +74,13 @@ namespace osu.Game.Screens.Play.HUD { wireframesPart = new ArgonCounterSpriteText(wireframesLookup) { - Anchor = anchor, - Origin = anchor, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, }, textPart = new ArgonCounterSpriteText(textLookup) { - Anchor = anchor, - Origin = anchor, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, }, } } @@ -110,7 +112,7 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(OsuColour colours) { - labelText.Colour = colours.Blue0; + LabelColour.Value = colours.Blue0; } protected override void LoadComplete() @@ -122,6 +124,12 @@ namespace osu.Game.Screens.Play.HUD labelText.Alpha = s.NewValue ? 1 : 0; NumberContainer.Y = s.NewValue ? 12 : 0; }, true); + LabelColour.BindValueChanged(c => labelText.Colour = c.NewValue, true); + TextColour.BindValueChanged(c => + { + textPart.Colour = c.NewValue; + wireframesPart.Colour = c.NewValue; + }, true); } private partial class ArgonCounterSpriteText : OsuSpriteText diff --git a/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs index 1620da2f2e..8e9360920c 100644 --- a/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonPerformancePointsCounter.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); public override bool IsValid diff --git a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs index 8658651407..f000a5977c 100644 --- a/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonScoreCounter.cs @@ -28,7 +28,7 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1, }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel), nameof(SkinnableComponentStrings.ShowLabelDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] public Bindable ShowLabel { get; } = new BindableBool(true); public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs index 8dc5d60352..5b2efb447b 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] public BindableBool UseRelativeSize { get; } = new BindableBool(true); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); [Resolved] diff --git a/osu.Game/Screens/Play/HUD/ArgonUnstableRateCounter.cs b/osu.Game/Screens/Play/HUD/ArgonUnstableRateCounter.cs new file mode 100644 index 0000000000..d6e6bc2111 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ArgonUnstableRateCounter.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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class ArgonUnstableRateCounter : UnstableRateCounter, ISerialisableDrawable + { + private ArgonCounterTextComponent text = null!; + + protected override double RollingDuration => 250; + + private const float alpha_when_invalid = 0.3f; + + [SettingSource("Wireframe opacity", "Controls the opacity of the wireframes behind the digits.")] + public BindableFloat WireframeOpacity { get; } = new BindableFloat(0.25f) + { + Precision = 0.01f, + MinValue = 0, + MaxValue = 1, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] + public Bindable ShowLabel { get; } = new BindableBool(true); + + protected override void LoadComplete() + { + base.LoadComplete(); + + IsValid.BindValueChanged(v => text.FadeTo(v.NewValue ? 1 : alpha_when_invalid, 1000, Easing.OutQuint), true); + FinishTransforms(true); + } + + public override int DisplayedCount + { + get => base.DisplayedCount; + set + { + base.DisplayedCount = value; + updateWireframe(); + } + } + + private void updateWireframe() + { + int digitsRequiredForDisplayCount = Math.Max(3, getDigitsRequiredForDisplayCount()); + + if (digitsRequiredForDisplayCount != text.WireframeTemplate.Length) + text.WireframeTemplate = new string('#', digitsRequiredForDisplayCount); + } + + private int getDigitsRequiredForDisplayCount() + { + int digitsRequired = 1; + long c = DisplayedCount; + while ((c /= 10) > 0) + digitsRequired++; + return digitsRequired; + } + + protected override IHasText CreateText() => text = new ArgonCounterTextComponent(Anchor.TopRight, "UR") + { + WireframeOpacity = { BindTarget = WireframeOpacity }, + ShowLabel = { BindTarget = ShowLabel }, + }; + } +} diff --git a/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs b/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs index 46a658cd1c..810100532b 100644 --- a/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs +++ b/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs @@ -22,7 +22,7 @@ namespace osu.Game.Screens.Play.HUD [SettingSource("Inverted shear")] public BindableBool InvertShear { get; } = new BindableBool(); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Color4Extensions.FromHex("#66CCFF")); public ArgonWedgePiece() diff --git a/osu.Game/Screens/Play/HUD/DefaultRankDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultRankDisplay.cs index 09ab7d156c..d768fedca4 100644 --- a/osu.Game/Screens/Play/HUD/DefaultRankDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultRankDisplay.cs @@ -2,44 +2,107 @@ // 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.Audio; +using osu.Game.Configuration; using osu.Game.Online.Leaderboards; +using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osu.Game.Skinning; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Screens.Play.HUD { - public partial class DefaultRankDisplay : Container, ISerialisableDrawable + public partial class DefaultRankDisplay : CompositeDrawable, ISerialisableDrawable { + public bool UsesFixedAnchor { get; set; } + [Resolved] private ScoreProcessor scoreProcessor { get; set; } = null!; - public bool UsesFixedAnchor { get; set; } + [SettingSource(typeof(DefaultRankDisplayStrings), nameof(DefaultRankDisplayStrings.PlaySamplesOnRankChange))] + public BindableBool PlaySamples { get; set; } = new BindableBool(true); - private readonly UpdateableRank rank; + private UpdateableRank rankDisplay = null!; + + private SkinnableSound rankDownSample = null!; + private SkinnableSound rankUpSample = null!; + + private Bindable lastSamplePlayback = null!; + private double lastChangeTime; + + private ScoreRank? displayedRank; + + private const int time_between_changes = 1500; public DefaultRankDisplay() { Size = new Vector2(70, 35); + } + [BackgroundDependencyLoader] + private void load(SkinEditor? skinEditor, SessionStatics statics) + { InternalChildren = new Drawable[] { - rank = new UpdateableRank(Scoring.ScoreRank.X) + rankDownSample = new SkinnableSound(new SampleInfo("Gameplay/rank-down")), + rankUpSample = new SkinnableSound(new SampleInfo("Gameplay/rank-up")), + rankDisplay = new UpdateableRank { RelativeSizeAxes = Axes.Both }, }; + + if (skinEditor != null) + PlaySamples.Value = false; + + lastSamplePlayback = statics.GetBindable(Static.LastRankChangeSamplePlaybackTime); } protected override void LoadComplete() { base.LoadComplete(); - rank.Rank = scoreProcessor.Rank.Value; + updateRank(scoreProcessor.Rank.Value); + } - scoreProcessor.Rank.BindValueChanged(v => rank.Rank = v.NewValue); + protected override void Update() + { + base.Update(); + + var currentRank = scoreProcessor.Rank.Value; + + if (currentRank == displayedRank) + return; + + if (Time.Current - lastChangeTime >= time_between_changes || scoreProcessor.HasCompleted.Value || currentRank == ScoreRank.F) + updateRank(currentRank); + } + + private void updateRank(ScoreRank rank) + { + rankDisplay.Rank = rank; + + // Check sample time separately to ensure two copies of the rank display don't both play samples on a change. + bool enoughSampleTimeElapsed = !lastSamplePlayback.Value.HasValue || Time.Current - lastSamplePlayback.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; + + // Also don't play rank-down sfx on quit/retry/initial update. + if (displayedRank != null && rank > ScoreRank.F && PlaySamples.Value && enoughSampleTimeElapsed) + { + if (rank > displayedRank) + rankUpSample.Play(); + else + rankDownSample.Play(); + + lastSamplePlayback.Value = Time.Current; + } + + displayedRank = rank; + lastChangeTime = Time.Current; } } -} \ No newline at end of file +} diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index 672017750d..06d541d838 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -40,7 +40,7 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] public BindableBool UseRelativeSize { get; } = new BindableBool(true); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); [Resolved] diff --git a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs new file mode 100644 index 0000000000..ddb926ebf1 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs @@ -0,0 +1,229 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class DrawableGameplayLeaderboard : CompositeDrawable, ISerialisableDrawable + { + protected readonly FillFlowContainer Flow; + + private bool requiresScroll; + private readonly OsuScrollContainer scroll; + + public DrawableGameplayLeaderboardScore? TrackedScore { get; private set; } + + public bool AlwaysShown { get; init; } + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.CollapseDuringGameplay), nameof(SkinnableComponentStrings.CollapseDuringGameplayDescription))] + public Bindable CollapseDuringGameplay { get; } = new BindableBool(true); + + private readonly Bindable expanded = new BindableBool(); + + [Resolved] + private Player? player { get; set; } + + [Resolved] + private IGameplayLeaderboardProvider leaderboardProvider { get; set; } = null!; + + private readonly IBindableList scores = new BindableList(); + private readonly Bindable configVisibility = new Bindable(); + private readonly IBindable userPlayingState = new Bindable(); + private readonly IBindable holdingForHUD = new Bindable(); + + /// + /// Create a new leaderboard. + /// + public DrawableGameplayLeaderboard() + { + // Extra lenience is applied so the scores don't get cut off from the left due to elastic easing transforms. + float xOffset = DrawableGameplayLeaderboardScore.SHEAR_WIDTH + DrawableGameplayLeaderboardScore.ELASTIC_WIDTH_LENIENCE; + + Width = 260 + xOffset; + Height = 300; + + InternalChildren = new Drawable[] + { + scroll = new InputDisabledScrollContainer + { + ClampExtension = 0, + RelativeSizeAxes = Axes.Both, + Child = Flow = new FillFlowContainer + { + Alpha = 0f, + RelativeSizeAxes = Axes.X, + X = xOffset, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2.5f), + LayoutDuration = 450, + LayoutEasing = Easing.OutQuint, + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, GameplayState? gameplayState, HUDOverlay? hudOverlay) + { + config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); + + if (gameplayState != null) + userPlayingState.BindTo(gameplayState.PlayingState); + + if (hudOverlay != null) + holdingForHUD.BindTo(hudOverlay.HoldingForHUD); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + scores.BindTo(leaderboardProvider.Scores); + scores.BindCollectionChanged((_, _) => + { + Clear(); + foreach (var score in scores) + Add(score); + }, true); + + configVisibility.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + userPlayingState.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + holdingForHUD.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + CollapseDuringGameplay.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + updateState(); + } + + private void updateState() + { + // prevents weird delay in the flow correctly appearing when toggling the leaderboard on. + if (Flow.Alpha < 1) + scroll.ScrollToStart(false); + + Flow.FadeTo(player?.Configuration.ShowLeaderboard != false && (configVisibility.Value || AlwaysShown) ? 1 : 0, 100, Easing.OutQuint); + expanded.Value = !CollapseDuringGameplay.Value || userPlayingState.Value != LocalUserPlayingState.Playing || holdingForHUD.Value; + } + + /// + /// Adds a player to the leaderboard. + /// + public void Add(GameplayLeaderboardScore score) + { + var drawable = CreateLeaderboardScoreDrawable(score); + + if (score.Tracked) + { + if (TrackedScore != null) + throw new InvalidOperationException("Cannot track more than one score."); + + TrackedScore = drawable; + } + + drawable.Expanded.BindTo(expanded); + + Flow.Add(drawable); + drawable.ScorePosition.BindValueChanged(_ => Scheduler.AddOnce(sort)); + drawable.DisplayOrder.BindValueChanged(_ => Scheduler.AddOnce(sort), true); + } + + public void Clear() + { + Flow.Clear(); + TrackedScore = null; + scroll.ScrollToStart(false); + } + + protected virtual DrawableGameplayLeaderboardScore CreateLeaderboardScoreDrawable(GameplayLeaderboardScore score) => + new DrawableGameplayLeaderboardScore(score); + + protected override void Update() + { + base.Update(); + + // limit leaderboard dimensions to a sane minimum. + Width = Math.Max(Width, Flow.X + DrawableGameplayLeaderboardScore.MIN_WIDTH); + Height = Math.Max(Height, DrawableGameplayLeaderboardScore.PANEL_HEIGHT); + + requiresScroll = Flow.DrawHeight > Height; + + if (requiresScroll && TrackedScore != null) + { + double scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; + + scroll.ScrollTo(scrollTarget); + } + + const float panel_height = DrawableGameplayLeaderboardScore.PANEL_HEIGHT; + + float fadeBottom = (float)(scroll.Current + scroll.DrawHeight); + float fadeTop = (float)(scroll.Current + panel_height); + + if (scroll.IsScrolledToStart()) fadeTop -= panel_height; + if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; + + // logic is mostly shared with Leaderboard, copied here for simplicity. + foreach (var c in Flow) + { + 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() + { + foreach (var score in Flow.ToArray()) + Flow.SetLayoutPosition(score, score.DisplayOrder.Value); + } + + private partial class InputDisabledScrollContainer : OsuScrollContainer + { + public InputDisabledScrollContainer() + { + ScrollbarVisible = false; + } + + public override bool HandlePositionalInput => false; + public override bool HandleNonPositionalInput => false; + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs new file mode 100644 index 0000000000..339488e5d0 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs @@ -0,0 +1,453 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class DrawableGameplayLeaderboardScore : CompositeDrawable + { + public const float MIN_WIDTH = extended_left_panel_width + avatar_size / 2 + 5; + + private const float left_panel_extension_width = 20; + + private const float regular_left_panel_width = avatar_size + avatar_size / 2; + private const float extended_left_panel_width = regular_left_panel_width + left_panel_extension_width; + + private const float accuracy_combo_width_cutoff = 150; + private const float username_score_width_cutoff = 50; + + private const float avatar_size = PANEL_HEIGHT; + + public const float PANEL_HEIGHT = 38f; + + public static readonly float SHEAR_WIDTH = PANEL_HEIGHT * OsuGame.SHEAR.X; + + /// + /// Extra width lenience to account for the out-of-range values produced by elastic easing when the score panel becomes extended (due to earning first score position or is a tracked score). + /// + public const float ELASTIC_WIDTH_LENIENCE = 10f; + + private const double panel_transition_duration = 500; + private const double text_transition_duration = 200; + + public Bindable Expanded { get; } = new BindableBool(); + + public BindableLong TotalScore { get; } = new BindableLong(); + public BindableDouble Accuracy { get; } = new BindableDouble(1); + public BindableInt Combo { get; } = new BindableInt(); + public BindableBool HasQuit { get; } = new BindableBool(); + public Bindable ScorePosition { get; } = new Bindable(); + public Bindable DisplayOrder { get; } = new Bindable(); + + private Func? getDisplayScoreFunction; + + public Func GetDisplayScore + { + set => getDisplayScoreFunction = value; + } + + public Color4? BackgroundColour { get; } + + public IUser? User { get; } + + /// + /// Whether this score is the local user or a replay player (and should be focused / always visible). + /// + public readonly bool Tracked; + + private FillFlowContainer scorePanel = null!; + private Container leftLayer = null!; + private Box leftLayerGradient = null!; + private Container rightLayer = null!; + private Box rightLayerGradient = null!; + private Container scoreComponents = null!; + private OsuSpriteText usernameText = null!; + private OsuSpriteText positionText = null!; + private OsuSpriteText accuracyText = null!; + private OsuSpriteText scoreText = null!; + private OsuSpriteText comboText = null!; + + private IBindable scoreDisplayMode = null!; + + private bool isFriend; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + /// + /// Creates a new . + /// + public DrawableGameplayLeaderboardScore(GameplayLeaderboardScore score) + { + User = score.User; + Tracked = score.Tracked; + TotalScore.BindTo(score.TotalScore); + Accuracy.BindTo(score.Accuracy); + Combo.BindTo(score.Combo); + HasQuit.BindTo(score.HasQuit); + ScorePosition.BindTo(score.Position); + DisplayOrder.BindTo(score.DisplayOrder); + GetDisplayScore = score.GetDisplayScore; + + if (score.TeamColour != null) + BackgroundColour = score.TeamColour.Value; + + RelativeSizeAxes = Axes.X; + Height = PANEL_HEIGHT; + + Shear = OsuGame.SHEAR; + + AddLayout(drawSizeLayout); + } + + [BackgroundDependencyLoader] + private void load() + { + const float corner_radius = 10; + + Container avatarLayer; + + InternalChild = scorePanel = new FillFlowContainer + { + CornerRadius = corner_radius, + BorderThickness = 2f, + Masking = true, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Children = new[] + { + // Apparently this whole dual layer thing is here because the design apparently called + // for a different colour to the left opposed to the right. + // + // I don't know this makes much visual sense. If it ever becomes an issue, rip it out + // and replace with a single gradient instead. + leftLayer = new Container + { + Width = regular_left_panel_width, + RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + leftLayerGradient = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = regular_left_panel_width, + // This may not be mathematically accurate but the position text looks best aligned with it. + Padding = new MarginPadding { Right = avatar_size / 2 - SHEAR_WIDTH / 2 }, + RelativeSizeAxes = Axes.Y, + Child = positionText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Shear = -OsuGame.SHEAR, + } + } + }, + }, + // this is placed here between the left and right layer for layout purposes, + // but it's proxied below to render in front of them. + avatarLayer = new Container + { + Size = new Vector2(avatar_size), + // precise padding so the avatar's top and bottom sides land as close to the panel borders as possible. + Padding = new MarginPadding(1.3f), + // negative left margin to place the avatar's center directly at the edge of the left layer. + Margin = new MarginPadding { Left = -avatar_size / 2 }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = corner_radius, + Masking = true, + Child = new ScoreAvatar(User) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Shear = -OsuGame.SHEAR, + // extra scaling to cover the entire sheared area. + Scale = new Vector2(1.1f), + }, + }, + }, + rightLayer = new Container + { + RelativeSizeAxes = Axes.Y, + // negative left margin to make the X position of the right layer directly at the avatar center (rendered behind it). + Margin = new MarginPadding { Left = -avatar_size / 2 }, + Children = new Drawable[] + { + rightLayerGradient = new Box + { + RelativeSizeAxes = Axes.Both, + }, + scoreComponents = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = avatar_size / 2 + 4, Right = 20, Vertical = 5 }, + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + usernameText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = User?.Username ?? string.Empty, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + }, + accuracyText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + }, + } + }, + }, + new GridContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + scoreText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Body.With(weight: FontWeight.Regular), + RelativeSizeAxes = Axes.X, + }, + comboText = new OsuSpriteText + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + }, + } + }, + }, + }, + } + } + }, + avatarLayer.CreateProxy(), + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + isFriend = User != null && api.LocalUserState.Friends.Any(u => User.OnlineID == u.TargetID); + + scoreDisplayMode = config.GetBindable(OsuSetting.ScoreDisplayMode); + scoreDisplayMode.BindValueChanged(_ => updateScore()); + TotalScore.BindValueChanged(_ => updateScore(), true); + + Accuracy.BindValueChanged(v => accuracyText.Text = v.NewValue.FormatAccuracy(), true); + + Combo.BindValueChanged(v => comboText.Text = $@"{v.NewValue}x", true); + + Expanded.BindValueChanged(onExpanded, true); + + HasQuit.BindValueChanged(_ => updatePanelState()); + ScorePosition.BindValueChanged(_ => updatePanelState(), true); + + FinishTransforms(true); + } + + private void updateScore() => scoreText.Text = (getDisplayScoreFunction?.Invoke(scoreDisplayMode.Value) ?? TotalScore.Value).ToString("N0"); + + private void onExpanded(ValueChangedEvent expanded) + { + if (expanded.NewValue) + { + rightLayer.ResizeWidthTo(computeRightLayerWidth(), panel_transition_duration, Easing.OutQuint); + scoreComponents.FadeIn(panel_transition_duration, Easing.OutQuint); + } + else + { + rightLayer.ResizeWidthTo(avatar_size / 2, panel_transition_duration, Easing.OutQuint); + scoreComponents.FadeOut(text_transition_duration, Easing.OutQuint); + } + } + + private void updatePanelState() + { + positionText.Text = ScorePosition.Value.HasValue ? $"#{ScorePosition.Value.Value.FormatRank()}" : "-"; + + Color4 usernameColour = Color4.White; + bool widthExtension = false; + + if (HasQuit.Value) + { + setPanelColour(Color4.Gray); + usernameColour = colours.Red2; + } + else if (ScorePosition.Value == 1) + { + widthExtension = true; + setPanelColour(BackgroundColour ?? colours.Lime2); + } + else if (Tracked) + { + widthExtension = true; + setPanelColourAsTracked(); + } + else if (isFriend) + { + setPanelColour(BackgroundColour ?? colours.Pink1); + usernameColour = colours.Pink1; + } + else + setPanelColour(BackgroundColour ?? colours.Blue4); + + usernameText.FadeColour(usernameColour, text_transition_duration, Easing.OutQuint); + + scorePanel.MoveToX(widthExtension ? 0 : left_panel_extension_width, panel_transition_duration, Easing.OutElastic); + leftLayer.ResizeWidthTo(widthExtension ? extended_left_panel_width : regular_left_panel_width, panel_transition_duration, Easing.OutElastic); + } + + private void setPanelColour(Color4 baseColour) + { + leftLayerGradient.Colour = ColourInfo.GradientVertical(baseColour.Opacity(0.2f), baseColour.Opacity(0.5f)); + rightLayerGradient.Colour = ColourInfo.GradientVertical(baseColour.Opacity(0.1f), baseColour.Opacity(0.3f)); + scorePanel.BorderColour = ColourInfo.GradientVertical(baseColour.Opacity(0.2f), baseColour); + } + + private void setPanelColourAsTracked() + { + leftLayerGradient.Colour = ColourInfo.GradientVertical(colours.Blue2.Opacity(0.3f), colours.Blue2); + rightLayerGradient.Colour = ColourInfo.GradientVertical(colours.Blue4.Opacity(0.25f), colours.Blue3.Opacity(0.6f)); + scorePanel.BorderColour = ColourInfo.GradientVertical(colours.Blue1.Opacity(0.2f), colours.Blue1); + } + + protected override void Update() + { + base.Update(); + + if (!drawSizeLayout.IsValid) + { + if (Expanded.Value) + { + rightLayer.ClearTransforms(targetMember: nameof(Width)); + rightLayer.Width = computeRightLayerWidth(); + } + + drawSizeLayout.Validate(); + } + + bool showAccuracyAndCombo = rightLayer.Width >= accuracy_combo_width_cutoff; + + accuracyText.Alpha = showAccuracyAndCombo ? 1 : 0; + comboText.Alpha = showAccuracyAndCombo ? 1 : 0; + + bool showUsernameAndScore = rightLayer.Width >= username_score_width_cutoff; + + usernameText.Alpha = showUsernameAndScore ? 1 : 0; + scoreText.Alpha = showUsernameAndScore ? 1 : 0; + } + + private float computeRightLayerWidth() => Math.Max(0, DrawWidth - extended_left_panel_width - avatar_size / 2); + + private partial class ScoreAvatar : CompositeDrawable + { + private readonly IUser? user; + + private Box placeholder = null!; + + public ScoreAvatar(IUser? user) + { + this.user = user; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = placeholder = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.1f), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + LoadComponentAsync(new DrawableAvatar(user), a => + { + placeholder.FadeOut(300, Easing.InQuint); + AddInternal(a); + }); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs deleted file mode 100644 index d2b6b834f8..0000000000 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Framework.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 abstract partial class GameplayLeaderboard : CompositeDrawable - { - private readonly Cached sorting = new Cached(); - - public Bindable Expanded = new Bindable(); - - protected readonly FillFlowContainer Flow; - - private bool requiresScroll; - private readonly OsuScrollContainer scroll; - - public GameplayLeaderboardScore? TrackedScore { get; private set; } - - private const int max_panels = 8; - - /// - /// Create a new leaderboard. - /// - protected GameplayLeaderboard() - { - Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH; - - InternalChildren = new Drawable[] - { - scroll = new InputDisabledScrollContainer - { - ClampExtension = 0, - 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() - { - base.LoadComplete(); - - Scheduler.AddDelayed(sort, 1000, true); - } - - /// - /// Adds a player to the leaderboard. - /// - /// The player. - /// - /// 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 Add(IUser? user, bool isTracked) - { - var drawable = CreateLeaderboardScoreDrawable(user, isTracked); - - 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); - drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true); - - int displayCount = Math.Min(Flow.Count, max_panels); - Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y); - requiresScroll = displayCount != Flow.Count; - - return drawable; - } - - public void Clear() - { - Flow.Clear(); - TrackedScore = null; - scroll.ScrollToStart(false); - } - - protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? 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); - } - - const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT; - - float fadeBottom = scroll.Current + scroll.DrawHeight; - float fadeTop = scroll.Current + panel_height; - - if (scroll.IsScrolledToStart()) fadeTop -= panel_height; - if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; - - // logic is mostly shared with Leaderboard, copied here for simplicity. - foreach (var c in Flow) - { - 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() - { - if (sorting.IsValid) - return; - - var orderedByScore = Flow - .OrderByDescending(i => i.TotalScore.Value) - .ThenBy(i => i.DisplayOrder.Value) - .ToList(); - - for (int i = 0; i < Flow.Count; i++) - { - Flow.SetLayoutPosition(orderedByScore[i], i); - orderedByScore[i].ScorePosition = CheckValidScorePosition(orderedByScore[i], i + 1) ? i + 1 : null; - } - - sorting.Validate(); - } - - protected virtual bool CheckValidScorePosition(GameplayLeaderboardScore score, int position) => true; - - private partial 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 deleted file mode 100644 index 3d46517a68..0000000000 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ /dev/null @@ -1,439 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.API; -using osu.Game.Rulesets.Scoring; -using osu.Game.Users; -using osu.Game.Users.Drawables; -using osu.Game.Utils; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Play.HUD -{ - public partial class GameplayLeaderboardScore : CompositeDrawable, ILeaderboardScore - { - public const float EXTENDED_WIDTH = regular_width + top_player_left_width_extension; - - private const float regular_width = 235f; - - // a bit hand-wavy, but there's a lot of hard-coded paddings in each of the grid's internals. - private const float compact_width = 77.5f; - - private const float top_player_left_width_extension = 20f; - - public const float PANEL_HEIGHT = 35f; - - public const float SHEAR_WIDTH = PANEL_HEIGHT * panel_shear; - - private const float panel_shear = 0.15f; - - private const float rank_text_width = 35f; - - private const float avatar_size = 25f; - - private const double panel_transition_duration = 500; - - private const double text_transition_duration = 200; - - public Bindable Expanded = new Bindable(); - - private OsuSpriteText positionText = null!, scoreText = null!, accuracyText = null!, comboText = null!, usernameText = null!; - - public BindableLong TotalScore { get; } = new BindableLong(); - public BindableDouble Accuracy { get; } = new BindableDouble(1); - public BindableInt Combo { get; } = new BindableInt(); - public BindableBool HasQuit { get; } = new BindableBool(); - public Bindable DisplayOrder { get; } = new Bindable(); - - private Func? getDisplayScoreFunction; - - public Func GetDisplayScore - { - set => getDisplayScoreFunction = value; - } - - public Color4? BackgroundColour { get; set; } - - public Color4? TextColour { get; set; } - - private int? scorePosition; - - private bool scorePositionIsSet; - - public int? ScorePosition - { - get => scorePosition; - set - { - // We always want to run once, as the incoming value may be null and require a visual update to "-". - if (value == scorePosition && scorePositionIsSet) - return; - - scorePosition = value; - - positionText.Text = scorePosition.HasValue ? $"#{scorePosition.Value.FormatRank()}" : "-"; - scorePositionIsSet = true; - - updateState(); - } - } - - public IUser? User { get; } - - /// - /// Whether this score is the local user or a replay player (and should be focused / always visible). - /// - public readonly bool Tracked; - - private Container mainFillContainer = null!; - - private Box centralFill = null!; - - private Container backgroundPaddingAdjustContainer = null!; - - private GridContainer gridContainer = null!; - - private Container scoreComponents = null!; - - private IBindable scoreDisplayMode = null!; - - private bool isFriend; - - /// - /// Creates a new . - /// - /// The score's player. - /// Whether the player is the local user or a replay player. - public GameplayLeaderboardScore(IUser? user, bool tracked) - { - User = user; - Tracked = tracked; - - AutoSizeAxes = Axes.X; - Height = PANEL_HEIGHT; - - GetDisplayScore = _ => TotalScore.Value; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours, OsuConfigManager osuConfigManager, IAPIProvider api) - { - Container avatarContainer; - - InternalChildren = new Drawable[] - { - new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Margin = new MarginPadding { Left = top_player_left_width_extension }, - Children = new Drawable[] - { - backgroundPaddingAdjustContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - mainFillContainer = new Container - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5f, - Shear = new Vector2(panel_shear, 0f), - Children = new Drawable[] - { - new Box - { - Alpha = 0.5f, - RelativeSizeAxes = Axes.Both, - }, - }, - }, - } - }, - gridContainer = new GridContainer - { - RelativeSizeAxes = Axes.Y, - Width = compact_width, // will be updated by expanded state. - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Absolute, rank_text_width), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - positionText = new OsuSpriteText - { - Padding = new MarginPadding { Right = SHEAR_WIDTH / 2 }, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = Color4.White, - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.Bold), - Shadow = false, - }, - new Container - { - Padding = new MarginPadding { Horizontal = SHEAR_WIDTH / 3 }, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Container - { - Masking = true, - CornerRadius = 5f, - Shear = new Vector2(panel_shear, 0f), - RelativeSizeAxes = Axes.Both, - Children = new[] - { - centralFill = new Box - { - Alpha = 0.5f, - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex("3399cc"), - }, - } - }, - new FillFlowContainer - { - Padding = new MarginPadding { Left = SHEAR_WIDTH }, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4f, 0f), - Children = new Drawable[] - { - avatarContainer = new CircularContainer - { - Masking = true, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(avatar_size), - Children = new Drawable[] - { - new Box - { - Name = "Placeholder while avatar loads", - Alpha = 0.3f, - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray4, - } - } - }, - usernameText = new TruncatingSpriteText - { - RelativeSizeAxes = Axes.X, - Width = 0.6f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = Color4.White, - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - Text = User?.Username ?? string.Empty, - Shadow = false, - } - } - }, - } - }, - scoreComponents = new Container - { - Padding = new MarginPadding { Top = 2f, Right = 17.5f, Bottom = 5f }, - AlwaysPresent = true, // required to smoothly animate autosize after hidden early. - Masking = true, - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = Color4.White, - Children = new Drawable[] - { - scoreText = new OsuSpriteText - { - Spacing = new Vector2(-1f, 0f), - Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold, fixedWidth: true), - Shadow = false, - }, - accuracyText = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold, fixedWidth: true), - Spacing = new Vector2(-1f, 0f), - Shadow = false, - }, - comboText = new OsuSpriteText - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Spacing = new Vector2(-1f, 0f), - Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold, fixedWidth: true), - Shadow = false, - }, - }, - } - } - } - } - } - }, - }; - - LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add); - - scoreDisplayMode = osuConfigManager.GetBindable(OsuSetting.ScoreDisplayMode); - scoreDisplayMode.BindValueChanged(_ => updateScore()); - TotalScore.BindValueChanged(_ => updateScore(), true); - - Accuracy.BindValueChanged(v => - { - accuracyText.Text = v.NewValue.FormatAccuracy(); - updateDetailsWidth(); - }, true); - - Combo.BindValueChanged(v => - { - comboText.Text = $"{v.NewValue}x"; - updateDetailsWidth(); - }, true); - - HasQuit.BindValueChanged(_ => updateState()); - - isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.TargetID); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - updateState(); - Expanded.BindValueChanged(changeExpandedState, true); - - FinishTransforms(true); - } - - private void updateScore() => scoreText.Text = (getDisplayScoreFunction?.Invoke(scoreDisplayMode.Value) ?? TotalScore.Value).ToString("N0"); - - private void changeExpandedState(ValueChangedEvent expanded) - { - if (expanded.NewValue) - { - gridContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutQuint); - - scoreComponents.FadeIn(panel_transition_duration, Easing.OutQuint); - - usernameText.FadeIn(panel_transition_duration, Easing.OutQuint); - } - else - { - gridContainer.ResizeWidthTo(compact_width, panel_transition_duration, Easing.OutQuint); - - scoreComponents.FadeOut(text_transition_duration, Easing.OutQuint); - - usernameText.FadeOut(text_transition_duration, Easing.OutQuint); - } - - updateDetailsWidth(); - } - - private float? scoreComponentsTargetWidth; - - private void updateDetailsWidth() - { - const float score_components_min_width = 88f; - - float newWidth = Expanded.Value - ? Math.Max(score_components_min_width, comboText.DrawWidth + accuracyText.DrawWidth + 25) - : 0; - - if (scoreComponentsTargetWidth == newWidth) - return; - - scoreComponentsTargetWidth = newWidth; - scoreComponents.ResizeWidthTo(newWidth, panel_transition_duration, Easing.OutQuint); - } - - private void updateState() - { - bool widthExtension = false; - - if (HasQuit.Value) - { - // we will probably want to display this in a better way once we have a design. - // and also show states other than quit. - panelColour = Color4.Gray; - textColour = Color4.White; - return; - } - - if (scorePosition == 1) - { - widthExtension = true; - panelColour = BackgroundColour ?? Color4Extensions.FromHex("7fcc33"); - textColour = TextColour ?? Color4.White; - } - else if (Tracked) - { - widthExtension = true; - panelColour = BackgroundColour ?? Color4Extensions.FromHex("ffd966"); - textColour = TextColour ?? Color4Extensions.FromHex("2e576b"); - } - else if (isFriend) - { - panelColour = BackgroundColour ?? Color4Extensions.FromHex("ff549a"); - textColour = TextColour ?? Color4.White; - } - else - { - 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); - } - - public float SizeContainerLeftPadding - { - get => backgroundPaddingAdjustContainer.Padding.Left; - set => backgroundPaddingAdjustContainer.Padding = new MarginPadding { Left = value }; - } - - private Color4 panelColour - { - set - { - mainFillContainer.FadeColour(value, panel_transition_duration, Easing.OutQuint); - centralFill.FadeColour(value, panel_transition_duration, Easing.OutQuint); - } - } - - private Color4 textColour - { - set - { - scoreText.FadeColour(value, text_transition_duration, Easing.OutQuint); - accuracyText.FadeColour(value, text_transition_duration, Easing.OutQuint); - comboText.FadeColour(value, text_transition_duration, Easing.OutQuint); - usernameText.FadeColour(value, text_transition_duration, Easing.OutQuint); - positionText.FadeColour(value, text_transition_duration, Easing.OutQuint); - } - } - } -} diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index a71a46ec2a..e27a7544c9 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -87,7 +87,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters const int bar_width = 2; const float chevron_size = 8; - hitWindows = HitWindows.GetAllAvailableWindows().ToArray(); + hitWindows = HitWindows.GetAllAvailableWindows().Where(w => w.result.IsHit()).ToArray(); InternalChild = new Container { diff --git a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs deleted file mode 100644 index 1a5d7fd9a8..0000000000 --- a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Bindables; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Screens.Play.HUD -{ - public interface ILeaderboardScore - { - BindableLong TotalScore { get; } - BindableDouble Accuracy { get; } - BindableInt Combo { get; } - - BindableBool HasQuit { get; } - - /// - /// An optional value to guarantee stable ordering. - /// Lower numbers will appear higher in cases of ties. - /// - Bindable DisplayOrder { get; } - - /// - /// A custom function which handles converting a score to a display score using a provide . - /// - /// - /// If no function is provided, will be used verbatim. - Func GetDisplayScore { set; } - } -} diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs index d69416f34a..77c03069be 100644 --- a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs @@ -20,7 +20,10 @@ namespace osu.Game.Screens.Play.HUD.JudgementCounter public readonly JudgementCount Result; - public JudgementCounter(JudgementCount result) => Result = result; + public JudgementCounter(JudgementCount result) + { + Result = result; + } public OsuSpriteText ResultName = null!; private FillFlowContainer flowContainer = null!; diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index b37d41e7a2..986bc525cc 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -8,7 +8,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; +using osu.Game.Localisation.SkinComponents; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osuTK; @@ -20,9 +22,24 @@ namespace osu.Game.Screens.Play.HUD /// public partial class ModDisplay : CompositeDrawable, IHasCurrentValue> { - private const int fade_duration = 1000; + public const float MOD_ICON_SCALE = 0.6f; - public ExpansionMode ExpansionMode = ExpansionMode.ExpandOnHover; + private ExpansionMode expansionMode = ExpansionMode.ExpandOnHover; + + public ExpansionMode ExpansionMode + { + get => expansionMode; + set + { + if (expansionMode == value) + return; + + expansionMode = value; + + if (IsLoaded) + updateExpansionMode(); + } + } private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); @@ -37,7 +54,25 @@ namespace osu.Game.Screens.Play.HUD } } - private readonly bool showExtendedInformation; + private bool showExtendedInformation; + + public bool ShowExtendedInformation + { + get => showExtendedInformation; + set + { + showExtendedInformation = value; + foreach (var icon in iconsContainer) + icon.ShowExtendedInformation = value; + } + } + + public FillDirection FillDirection + { + get => iconsContainer.Direction; + set => iconsContainer.Direction = value; + } + private readonly FillFlowContainer iconsContainer; public ModDisplay(bool showExtendedInformation = true) @@ -58,11 +93,7 @@ namespace osu.Game.Screens.Play.HUD base.LoadComplete(); Current.BindValueChanged(updateDisplay, true); - - iconsContainer.FadeInFromZero(fade_duration, Easing.OutQuint); - - if (ExpansionMode == ExpansionMode.AlwaysExpanded || ExpansionMode == ExpansionMode.AlwaysContracted) - FinishTransforms(true); + updateExpansionMode(0); } private void updateDisplay(ValueChangedEvent> mods) @@ -70,29 +101,40 @@ namespace osu.Game.Screens.Play.HUD iconsContainer.Clear(); foreach (Mod mod in mods.NewValue.AsOrdered()) - iconsContainer.Add(new ModIcon(mod, showExtendedInformation: showExtendedInformation) { Scale = new Vector2(0.6f) }); - - appearTransform(); + iconsContainer.Add(new ModIcon(mod, showExtendedInformation: showExtendedInformation) { Scale = new Vector2(MOD_ICON_SCALE) }); } - private void appearTransform() + private void updateExpansionMode(double duration = 500) { - expand(); + switch (expansionMode) + { + case ExpansionMode.AlwaysExpanded: + expand(duration); + break; - using (iconsContainer.BeginDelayedSequence(1200)) - contract(); + case ExpansionMode.AlwaysContracted: + contract(duration); + break; + + case ExpansionMode.ExpandOnHover: + if (IsHovered) + expand(duration); + else + contract(duration); + break; + } } - private void expand() + private void expand(double duration = 500) { if (ExpansionMode != ExpansionMode.AlwaysContracted) - iconsContainer.TransformSpacingTo(new Vector2(5, 0), 500, Easing.OutQuint); + iconsContainer.TransformSpacingTo(new Vector2(5, -10), duration, Easing.OutQuint); } - private void contract() + private void contract(double duration = 500) { if (ExpansionMode != ExpansionMode.AlwaysExpanded) - iconsContainer.TransformSpacingTo(new Vector2(-25, 0), 500, Easing.OutQuint); + iconsContainer.TransformSpacingTo(new Vector2(-25), duration, Easing.OutQuint); } protected override bool OnHover(HoverEvent e) @@ -113,16 +155,19 @@ namespace osu.Game.Screens.Play.HUD /// /// The will expand only when hovered. /// + [LocalisableDescription(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.ExpandOnHover))] ExpandOnHover, /// /// The will always be expanded. /// + [LocalisableDescription(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.AlwaysExpanded))] AlwaysExpanded, /// /// The will always be contracted. /// - AlwaysContracted + [LocalisableDescription(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.AlwaysContracted))] + AlwaysContracted, } } diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index 18d7f6a503..635d140a4a 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.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.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; @@ -43,6 +45,15 @@ namespace osu.Game.Screens.Play.HUD private InputManager inputManager = null!; + [Resolved] + private HUDOverlay? hudOverlay { get; set; } + + // Player settings are kept off the edge of the screen. + // + // In edge cases, floating point error could result in the whole control getting masked away + // while collapsed down, so let's avoid that. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + public PlayerSettingsOverlay() : base(0, EXPANDED_WIDTH) { @@ -62,6 +73,11 @@ namespace osu.Game.Screens.Play.HUD } }); + // For future consideration, this icon should probably not exist. + // + // If we remove it, the following needs attention: + // - Mobile support (swipe from side of screen?) + // - Consolidating this overlay with the one at player loader (to have the animation hint at its presence) AddInternal(button = new IconButton { Icon = FontAwesome.Solid.Cog, @@ -86,11 +102,37 @@ namespace osu.Game.Screens.Play.HUD inputManager = GetContainingInputManager()!; } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + screenSpacePos.X > button.ScreenSpaceDrawQuad.TopLeft.X; + + protected override bool OnMouseMove(MouseMoveEvent e) + { + checkExpanded(); + return base.OnMouseMove(e); + } + protected override void Update() { base.Update(); - Expanded.Value = inputManager.CurrentState.Mouse.Position.X >= button.ScreenSpaceDrawQuad.TopLeft.X; + if (hudOverlay != null) + button.Y = ToLocalSpace(hudOverlay.TopRightElements.ScreenSpaceDrawQuad.BottomRight).Y; + + // Only check expanded if already expanded. + // This is because if we are always checking, it would bypass blocking overlays. + // Case in point: the skin editor overlay blocks input from reaching the player, but checking raw coordinates would make settings pop out. + if (Expanded.Value) + checkExpanded(); + } + + private void checkExpanded() + { + float screenMouseX = inputManager.CurrentState.Mouse.Position.X; + + Expanded.Value = + (screenMouseX >= button.ScreenSpaceDrawQuad.TopLeft.X && screenMouseX <= ToScreenSpace(new Vector2(DrawWidth + EXPANDED_WIDTH, 0)).X) + // Stay expanded if the user is dragging a slider. + || inputManager.DraggedDrawable != null; } protected override void OnHoverLost(HoverLostEvent e) diff --git a/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs b/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs new file mode 100644 index 0000000000..3f72099a45 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/PlayerTeamFlag.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Skinning; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class PlayerTeamFlag : CompositeDrawable, ISerialisableDrawable + { + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => false; + + private readonly UpdateableTeamFlag flag; + + private const float default_size = 40f; + + [Resolved] + private GameplayState? gameplayState { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IBindable? apiUser; + + public PlayerTeamFlag() + { + Size = new Vector2(default_size, default_size / 2f); + + InternalChild = flag = new UpdateableTeamFlag + { + RelativeSizeAxes = Axes.Both, + }; + } + + [BackgroundDependencyLoader] + private void load(UserLookupCache userLookupCache) + { + if (gameplayState != null) + { + if (gameplayState.Score.ScoreInfo.User.Team != null) + flag.Team = gameplayState.Score.ScoreInfo.User.Team; + else + { + // We only store very basic information about a user to realm, so there's a high chance we don't have the team information. + userLookupCache.GetUserAsync(gameplayState.Score.ScoreInfo.User.Id) + .ContinueWith(task => Schedule(() => flag.Team = task.GetResultSafely()?.Team)); + } + } + else + { + apiUser = api.LocalUser.GetBoundCopy(); + apiUser.BindValueChanged(u => flag.Team = u.NewValue.Team, true); + } + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableModDisplay.cs b/osu.Game/Screens/Play/HUD/SkinnableModDisplay.cs new file mode 100644 index 0000000000..29b8429539 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableModDisplay.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// Displays a single-line horizontal auto-sized flow of mods. For cases where wrapping is required, use instead. + /// + public partial class SkinnableModDisplay : CompositeDrawable, ISerialisableDrawable + { + private ModDisplay modDisplay = null!; + + [Resolved] + private Bindable> mods { get; set; } = null!; + + [SettingSource(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.ShowExtendedInformation), nameof(SkinnableModDisplayStrings.ShowExtendedInformationDescription))] + public Bindable ShowExtendedInformation { get; } = new Bindable(true); + + [SettingSource(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.ExpansionMode), nameof(SkinnableModDisplayStrings.ExpansionModeDescription))] + public Bindable ExpansionModeSetting { get; } = new Bindable(); + + [SettingSource(typeof(SkinnableModDisplayStrings), nameof(SkinnableModDisplayStrings.DisplayDirection))] + public Bindable Direction { get; } = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + // Provide a minimum autosize. + new Container { Size = ModIcon.MOD_ICON_SIZE * ModDisplay.MOD_ICON_SCALE }, + modDisplay = new ModDisplay(), + }; + + modDisplay.Current = mods; + AutoSizeAxes = Axes.Both; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ShowExtendedInformation.BindValueChanged(_ => modDisplay.ShowExtendedInformation = ShowExtendedInformation.Value, true); + ExpansionModeSetting.BindValueChanged(_ => modDisplay.ExpansionMode = ExpansionModeSetting.Value, true); + Direction.BindValueChanged(_ => modDisplay.FillDirection = Direction.Value == Framework.Graphics.Direction.Horizontal ? FillDirection.Horizontal : FillDirection.Vertical, true); + + FinishTransforms(true); + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs deleted file mode 100644 index e9bb1d2101..0000000000 --- a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Configuration; -using osu.Game.Online.API.Requests; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Scoring.Legacy; -using osu.Game.Screens.Select; -using osu.Game.Users; - -namespace osu.Game.Screens.Play.HUD -{ - public partial class SoloGameplayLeaderboard : GameplayLeaderboard - { - private const int duration = 100; - - private readonly Bindable configVisibility = new Bindable(); - - private readonly Bindable scoreSource = new Bindable(); - - private readonly IUser trackingUser; - - public readonly IBindableList Scores = new BindableList(); - - [Resolved] - private ScoreProcessor scoreProcessor { get; set; } = null!; - - /// - /// Whether the leaderboard should be visible regardless of the configuration value. - /// This is true by default, but can be changed. - /// - public readonly Bindable AlwaysVisible = new Bindable(true); - - public SoloGameplayLeaderboard(IUser trackingUser) - { - this.trackingUser = trackingUser; - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); - config.BindWith(OsuSetting.BeatmapDetailTab, scoreSource); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Scores.BindCollectionChanged((_, _) => Scheduler.AddOnce(showScores), true); - - // Alpha will be updated via `updateVisibility` below. - Alpha = 0; - - AlwaysVisible.BindValueChanged(_ => updateVisibility()); - configVisibility.BindValueChanged(_ => updateVisibility(), true); - } - - private void showScores() - { - Clear(); - - if (!Scores.Any()) - return; - - foreach (var s in Scores) - { - var score = Add(s.User, false); - - score.GetDisplayScore = s.GetDisplayScore; - score.TotalScore.Value = s.TotalScore; - score.Accuracy.Value = s.Accuracy; - score.Combo.Value = s.MaxCombo; - score.DisplayOrder.Value = s.OnlineID > 0 ? s.OnlineID : s.Date.ToUnixTimeSeconds(); - } - - ILeaderboardScore local = Add(trackingUser, true); - - local.GetDisplayScore = scoreProcessor.GetDisplayScore; - local.TotalScore.BindTarget = scoreProcessor.TotalScore; - local.Accuracy.BindTarget = scoreProcessor.Accuracy; - local.Combo.BindTarget = scoreProcessor.HighestCombo; - - // Local score should always show lower than any existing scores in cases of ties. - local.DisplayOrder.Value = long.MaxValue; - } - - protected override bool CheckValidScorePosition(GameplayLeaderboardScore score, int position) - { - // change displayed position to '-' when there are 50 already submitted scores and tracked score is last - if (score.Tracked && scoreSource.Value != PlayBeatmapDetailArea.TabType.Local) - { - if (position == Flow.Count && Flow.Count > GetScoresRequest.MAX_SCORES_PER_REQUEST) - return false; - } - - return base.CheckValidScorePosition(score, position); - } - - private void updateVisibility() => - this.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration); - } -} diff --git a/osu.Game/Screens/Play/HUD/SpectatorList.cs b/osu.Game/Screens/Play/HUD/SpectatorList.cs new file mode 100644 index 0000000000..0660c1c8db --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SpectatorList.cs @@ -0,0 +1,301 @@ +// Copyright (c) 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.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation.HUD; +using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class SpectatorList : CompositeDrawable, ISerialisableDrawable + { + private const int max_spectators_displayed = 10; + + public Bindable HeaderFont { get; } = new Bindable(Typeface.Torus); + public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White); + + private IBindableList watchingUsers { get; } = new BindableList(); + private IBindableList multiplayerPlayers { get; } = new BindableList(); + private BindableList actualSpectators { get; } = new BindableList(); + + private Bindable userPlayingState { get; } = new Bindable(); + + private OsuSpriteText header = null!; + private FillFlowContainer mainFlow = null!; + private FillFlowContainer spectatorsFlow = null!; + private DrawablePool pool = null!; + + [Resolved] + private SpectatorClient client { get; set; } = null!; + + [Resolved] + private GameplayState gameplayState { get; set; } = null!; + + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + Empty().With(t => t.Size = new Vector2(100, 50)), + mainFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + header = new OsuSpriteText + { + Colour = colours.Blue0, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + }, + spectatorsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + } + } + }, + pool = new DrawablePool(max_spectators_displayed), + }; + + HeaderColour.Value = header.Colour; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ((IBindable)userPlayingState).BindTo(gameplayState.PlayingState); + + multiplayerPlayers.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); + multiplayerPlayers.BindCollectionChanged((_, _) => removePlayersFromMultiplayerRoom()); + + watchingUsers.BindTo(client.WatchingUsers); + watchingUsers.BindCollectionChanged(onWatchingUsersChanged, true); + + actualSpectators.BindCollectionChanged(onSpectatorsChanged, true); + userPlayingState.BindValueChanged(_ => updateVisibility()); + + HeaderFont.BindValueChanged(_ => updateAppearance()); + HeaderColour.BindValueChanged(_ => updateAppearance(), true); + FinishTransforms(true); + + this.FadeInFromZero(200, Easing.OutQuint); + } + + private void onWatchingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + for (int i = 0; i < e.NewItems!.Count; i++) + actualSpectators.Add((SpectatorUser)e.NewItems![i]!); + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + for (int i = 0; i < e.OldItems!.Count; i++) + actualSpectators.Remove((SpectatorUser)e.OldItems![i]!); + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + actualSpectators.Clear(); + break; + } + + default: + throw new NotSupportedException(); + } + + removePlayersFromMultiplayerRoom(); + } + + private void removePlayersFromMultiplayerRoom() + { + // the multiplayer gameplay leaderboard relies on calling `SpectatorClient.WatchUser()` to get updates on users' total scores. + // this has an unfortunate side effect of other players showing up in `SpectatorClient.WatchingUsers`. + // + // we do not generally wish to display other players in the room as spectators due to that implementation detail, + // therefore this code is intended to filter out those players on the client side. + actualSpectators.RemoveAll(s => multiplayerPlayers.Contains(s.OnlineID)); + } + + private void onSpectatorsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + for (int i = 0; i < e.NewItems!.Count; i++) + { + var spectator = (SpectatorUser)e.NewItems![i]!; + int index = Math.Max(e.NewStartingIndex, 0) + i; + + if (index >= max_spectators_displayed) + break; + + addNewSpectatorToList(index, spectator); + } + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + spectatorsFlow.RemoveAll(entry => e.OldItems!.Contains(entry.Current.Value), false); + + for (int i = 0; i < spectatorsFlow.Count; i++) + spectatorsFlow.SetLayoutPosition(spectatorsFlow[i], i); + + if (actualSpectators.Count >= max_spectators_displayed && spectatorsFlow.Count < max_spectators_displayed) + { + for (int i = spectatorsFlow.Count; i < max_spectators_displayed; i++) + addNewSpectatorToList(i, actualSpectators[i]); + } + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + spectatorsFlow.Clear(false); + break; + } + + default: + throw new NotSupportedException(); + } + + header.Text = SpectatorListStrings.SpectatorCount(actualSpectators.Count).ToUpper(); + updateVisibility(); + + for (int i = 0; i < spectatorsFlow.Count; i++) + { + spectatorsFlow[i].Colour = i < max_spectators_displayed - 1 + ? Color4.White + : ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0)); + } + } + + private void addNewSpectatorToList(int i, SpectatorUser spectator) + { + var entry = pool.Get(entry => + { + entry.Current.Value = spectator; + entry.UserPlayingState = userPlayingState; + }); + + spectatorsFlow.Insert(i, entry); + } + + private void updateVisibility() + { + // We don't want to show spectators when we are watching a replay. + mainFlow.FadeTo(actualSpectators.Count > 0 && userPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint); + } + + private void updateAppearance() + { + header.Font = OsuFont.GetFont(HeaderFont.Value, 12, FontWeight.Bold); + header.Colour = HeaderColour.Value; + + Width = header.DrawWidth; + } + + private partial class SpectatorListEntry : PoolableDrawable + { + public Bindable Current { get; } = new Bindable(); + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable UserPlayingState + { + get => current.Current; + set => current.Current = value; + } + + private OsuSpriteText username = null!; + private DrawableLinkCompiler? linkCompiler; + + [Resolved] + private OsuGame? game { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + username = new OsuSpriteText(), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + UserPlayingState.BindValueChanged(_ => updateEnabledState()); + Current.BindValueChanged(_ => updateState(), true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + username.MoveToX(10) + .Then() + .MoveToX(0, 400, Easing.OutQuint); + + this.FadeInFromZero(400, Easing.OutQuint); + } + + private void updateState() + { + username.Text = Current.Value.Username; + linkCompiler?.Expire(); + AddInternal(linkCompiler = new DrawableLinkCompiler([username]) + { + IdleColour = Colour4.White, + Action = () => game?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, Current.Value)), + }); + updateEnabledState(); + } + + private void updateEnabledState() + { + if (linkCompiler != null) + linkCompiler.Enabled.Value = UserPlayingState.Value != LocalUserPlayingState.Playing; + } + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs index a856a09388..b8ac6e00c6 100644 --- a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs +++ b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs @@ -5,46 +5,29 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; -using osuTK; namespace osu.Game.Screens.Play.HUD { - public partial class UnstableRateCounter : RollingCounter, ISerialisableDrawable + public abstract partial class UnstableRateCounter : RollingCounter { public bool UsesFixedAnchor { get; set; } protected override double RollingDuration => 375; - private const float alpha_when_invalid = 0.3f; - private readonly Bindable valid = new Bindable(); - private HitEventExtensions.UnstableRateCalculationResult? unstableRateResult; [Resolved] private ScoreProcessor scoreProcessor { get; set; } = null!; - public UnstableRateCounter() + protected UnstableRateCounter() { Current.Value = 0; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Colour = colours.BlueLighter; - valid.BindValueChanged(e => - DrawableCount.FadeTo(e.NewValue ? 1 : alpha_when_invalid, 1000, Easing.OutQuint)); - } + public Bindable IsValid { get; } = new Bindable(); protected override void LoadComplete() { @@ -67,17 +50,12 @@ namespace osu.Game.Screens.Play.HUD double? unstableRate = unstableRateResult?.Result; - valid.Value = unstableRate != null; + IsValid.Value = unstableRate != null; if (unstableRate != null) Current.Value = (int)Math.Round(unstableRate.Value); } - protected override IHasText CreateText() => new TextComponent - { - Alpha = alpha_when_invalid, - }; - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -88,44 +66,5 @@ namespace osu.Game.Screens.Play.HUD scoreProcessor.JudgementReverted -= updateDisplay; } } - - private partial 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, fixedWidth: true) - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.Numeric.With(size: 8, fixedWidth: true), - Text = @"UR", - Padding = new MarginPadding { Bottom = 1.5f }, // align baseline better - } - } - }; - } - } } } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index fca871e42f..806e593729 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Configuration; +using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Overlays; @@ -50,7 +51,7 @@ namespace osu.Game.Screens.Play return base.ShouldBeConsideredForInput(child); // hold to quit button should always be interactive. - return child == bottomRightElements; + return child == BottomRightElements; } public readonly ModDisplay ModDisplay; @@ -86,8 +87,13 @@ namespace osu.Game.Screens.Play private static bool hasShownNotificationOnce; - private readonly FillFlowContainer bottomRightElements; - private readonly FillFlowContainer topRightElements; + // The following flows are used to attach fixed non-skinnable elements in particular implementations of the player + // (e.g. replay or multiplayer-specific controls). + // They will make a best-effort attempt to get out of the way of any other skinnable components. + + public readonly FillFlowContainer TopLeftElements; + public readonly FillFlowContainer TopRightElements; + public readonly FillFlowContainer BottomRightElements; internal readonly IBindable IsPlaying = new Bindable(); @@ -100,12 +106,6 @@ namespace osu.Game.Screens.Play [CanBeNull] private readonly SkinnableContainer rulesetComponents; - /// - /// A flow which sits at the left side of the screen to house leaderboard (and related) components. - /// Will automatically be positioned to avoid colliding with top scoring elements. - /// - public readonly FillFlowContainer LeaderboardFlow; - private readonly List hideTargets; /// @@ -113,7 +113,7 @@ namespace osu.Game.Screens.Play /// internal readonly Drawable PlayfieldSkinLayer; - public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods, bool alwaysShowLeaderboard = true) + public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods) { Container rightSettings; @@ -136,7 +136,7 @@ namespace osu.Game.Screens.Play PlayfieldSkinLayer = drawableRuleset != null ? new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.Playfield, drawableRuleset.Ruleset.RulesetInfo)) { AlwaysPresent = true, } : Empty(), - topRightElements = new FillFlowContainer + TopRightElements = new FillFlowContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, @@ -147,10 +147,13 @@ namespace osu.Game.Screens.Play Direction = FillDirection.Vertical, Children = new Drawable[] { + // This display is potentially a duplicate of users with a local ModDisplay in their skins. + // It would be very nice to remove this, but the version here has special logic with regards to replays + // and initial states, so needs a bit of thought before doing so. ModDisplay = CreateModsContainer(), } }, - bottomRightElements = new FillFlowContainer + BottomRightElements = new FillFlowContainer { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, @@ -173,7 +176,7 @@ namespace osu.Game.Screens.Play PlayerSettingsOverlay = new PlayerSettingsOverlay(), } }, - LeaderboardFlow = new FillFlowContainer + TopLeftElements = new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, @@ -182,17 +185,16 @@ namespace osu.Game.Screens.Play }, }; - hideTargets = new List { mainComponents, topRightElements, rightSettings }; + hideTargets = new List { mainComponents, TopRightElements, rightSettings }; if (rulesetComponents != null) hideTargets.Add(rulesetComponents); - if (!alwaysShowLeaderboard) - hideTargets.Add(LeaderboardFlow); + hideTargets.Add(TopLeftElements); } [BackgroundDependencyLoader(true)] - private void load(OsuConfigManager config, INotificationOverlay notificationOverlay) + private void load(OsuConfigManager config, RealmKeyBindingStore keyBindingStore, INotificationOverlay notificationOverlay) { if (drawableRuleset != null) { @@ -211,7 +213,7 @@ namespace osu.Game.Screens.Play notificationOverlay?.Post(new SimpleNotification { - Text = NotificationsStrings.ScoreOverlayDisabled(config.LookupKeyBindings(GlobalAction.ToggleInGameInterface)) + Text = NotificationsStrings.ScoreOverlayDisabled(keyBindingStore.GetBindingsStringFor(GlobalAction.ToggleInGameInterface)) }); } @@ -237,7 +239,7 @@ namespace osu.Game.Screens.Play { if (e.NewValue) { - ModDisplay.FadeIn(200); + ModDisplay.FadeIn(1000, FADE_EASING); InputCountController.Margin = new MarginPadding(10) { Bottom = 30 }; } else @@ -248,6 +250,9 @@ namespace osu.Game.Screens.Play updateVisibility(); }, true); + + ModDisplay.ExpansionMode = ExpansionMode.AlwaysExpanded; + Scheduler.AddDelayed(() => ModDisplay.ExpansionMode = ExpansionMode.ExpandOnHover, 1200); } protected override void Update() @@ -274,20 +279,20 @@ namespace osu.Game.Screens.Play if (rulesetComponents != null) processDrawables(rulesetComponents); - if (lowestTopScreenSpaceRight.HasValue) - topRightElements.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - topRightElements.DrawHeight); + if (lowestTopScreenSpaceRight.HasValue && DrawHeight - TopRightElements.DrawHeight > 0) + TopRightElements.Y = Math.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - TopRightElements.DrawHeight); else - topRightElements.Y = 0; + TopRightElements.Y = 0; - if (lowestTopScreenSpaceLeft.HasValue) - LeaderboardFlow.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - LeaderboardFlow.DrawHeight); + if (lowestTopScreenSpaceLeft.HasValue && DrawHeight - TopLeftElements.DrawHeight > 0) + TopLeftElements.Y = Math.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - TopLeftElements.DrawHeight); else - LeaderboardFlow.Y = 0; + TopLeftElements.Y = 0; - if (highestBottomScreenSpace.HasValue) - bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); + if (highestBottomScreenSpace.HasValue && DrawHeight - BottomRightElements.DrawHeight > 0) + BottomRightElements.Y = BottomScoringElementsHeight = -Math.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - BottomRightElements.DrawHeight); else - bottomRightElements.Y = 0; + BottomRightElements.Y = 0; void processDrawables(SkinnableContainer components) { @@ -411,7 +416,7 @@ namespace osu.Game.Screens.Play case GlobalAction.HoldForHUD: holdingForHUD.Value = true; - return true; + return false; case GlobalAction.ToggleInGameInterface: switch (configVisibilityMode.Value) diff --git a/osu.Game/Screens/Play/IGameplayClock.cs b/osu.Game/Screens/Play/IGameplayClock.cs index ad28e343ff..bef7362aa9 100644 --- a/osu.Game/Screens/Play/IGameplayClock.cs +++ b/osu.Game/Screens/Play/IGameplayClock.cs @@ -18,6 +18,11 @@ namespace osu.Game.Screens.Play /// double StartTime { get; } + /// + /// The time from which actual gameplay should start. When intro time is skipped, this will be the seeked location. + /// + double GameplayStartTime { get; } + /// /// All adjustments applied to this clock which come from mods. /// diff --git a/osu.Game/Screens/Play/KiaiGameplayFountains.cs b/osu.Game/Screens/Play/KiaiGameplayFountains.cs index fd9596c838..826c60c6cf 100644 --- a/osu.Game/Screens/Play/KiaiGameplayFountains.cs +++ b/osu.Game/Screens/Play/KiaiGameplayFountains.cs @@ -1,15 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable using System; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; using osu.Game.Screens.Menu; @@ -22,6 +19,8 @@ namespace osu.Game.Screens.Play private Bindable kiaiStarFountains = null!; + private StarFountainSounds sounds = null!; + [BackgroundDependencyLoader] private void load(OsuConfigManager config) { @@ -29,7 +28,7 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both; - Children = new[] + Children = new Drawable[] { leftFountain = new GameplayStarFountain { @@ -43,38 +42,35 @@ namespace osu.Game.Screens.Play Origin = Anchor.BottomRight, X = -75, }, + sounds = new StarFountainSounds(), }; } private bool isTriggered; - private double? lastTrigger; - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + protected override void Update() { - base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + base.Update(); if (!kiaiStarFountains.Value) return; - if (effectPoint.KiaiMode && !isTriggered) + if (EffectPoint.KiaiMode && !isTriggered) { - bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - effectPoint.Time) < 500; + bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - EffectPoint.Time) < 500; if (isNearEffectPoint) Shoot(); } - isTriggered = effectPoint.KiaiMode; + isTriggered = EffectPoint.KiaiMode; } public void Shoot() { - if (lastTrigger != null && Clock.CurrentTime - lastTrigger < 500) - return; - leftFountain.Shoot(1); rightFountain.Shoot(-1); - lastTrigger = Clock.CurrentTime; + + sounds.Play(); } public partial class GameplayStarFountain : StarFountain diff --git a/osu.Game/Screens/Play/LetterboxOverlay.cs b/osu.Game/Screens/Play/LetterboxOverlay.cs new file mode 100644 index 0000000000..f5c762ccf2 --- /dev/null +++ b/osu.Game/Screens/Play/LetterboxOverlay.cs @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Utils; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play +{ + public partial class LetterboxOverlay : CompositeDrawable + { + public required BreakTracker BreakTracker { get; init; } + + private readonly Container fadeContainer; + + private readonly IBindable currentPeriod = new Bindable(); + + public LetterboxOverlay() + { + RelativeSizeAxes = Axes.Both; + const float letterbox_height = 0.125f; + + InternalChild = fadeContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + RelativeSizeAxes = Axes.Both, + Height = letterbox_height, + Colour = Color4.Black, + }, + new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = letterbox_height, + Colour = Color4.Black, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentPeriod.BindTo(BreakTracker.CurrentPeriod); + currentPeriod.BindValueChanged(updateDisplay, true); + } + + public override bool RemoveCompletedTransforms => false; + + private void updateDisplay(ValueChangedEvent period) + { + if (period.NewValue == null) + return; + + var b = period.NewValue.Value; + + using (BeginAbsoluteSequence(b.Start)) + { + fadeContainer.FadeInFromZero(BreakOverlay.BREAK_FADE_DURATION); + using (BeginDelayedSequence(b.Duration)) + fadeContainer.FadeOut(BreakOverlay.BREAK_FADE_DURATION); + } + } + } +} diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 3851806788..c9db6009d0 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -12,6 +12,7 @@ using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays; +using osu.Game.Storyboards; namespace osu.Game.Screens.Play { @@ -53,50 +54,47 @@ namespace osu.Game.Screens.Play private readonly Bindable playbackRateValid = new Bindable(true); - private readonly WorkingBeatmap beatmap; + private readonly IBeatmap beatmap; private Track track; - private readonly double skipTargetTime; - [Resolved] private MusicController musicController { get; set; } = null!; /// /// Create a new master gameplay clock container. /// - /// The beatmap to be used for time and metadata references. - /// The latest time which should be used when introducing gameplay. Will be used when skipping forward. - public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime) - : base(beatmap.Track, applyOffsets: true, requireDecoupling: true) + /// The beatmap to be used for time and metadata references. + /// The latest time which should be used when introducing gameplay. Will be used when skipping forward. + public MasterGameplayClockContainer(WorkingBeatmap working, double gameplayStartTime) + : base(working.Track, applyOffsets: true, requireDecoupling: true) { - this.beatmap = beatmap; - this.skipTargetTime = skipTargetTime; + beatmap = working.Beatmap; + track = working.Track; - track = beatmap.Track; - - StartTime = findEarliestStartTime(); + GameplayStartTime = gameplayStartTime; + StartTime = findEarliestStartTime(gameplayStartTime, beatmap, working.Storyboard); } - private double findEarliestStartTime() + private static double findEarliestStartTime(double gameplayStartTime, IBeatmap beatmap, Storyboard storyboard) { // here we are trying to find the time to start playback from the "zero" point. // generally this is either zero, or some point earlier than zero in the case of storyboards, lead-ins etc. // start with the originally provided latest time (if before zero). - double time = Math.Min(0, skipTargetTime); + double time = Math.Min(0, gameplayStartTime); // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. // this is commonly used to display an intro before the audio track start. - double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; + double? firstStoryboardEvent = storyboard.EarliestEventTime; if (firstStoryboardEvent != null) time = Math.Min(time, firstStoryboardEvent.Value); // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. // this is not available as an option in the live editor but can still be applied via .osu editing. - double firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; - if (beatmap.Beatmap.AudioLeadIn > 0) - time = Math.Min(time, firstHitObjectTime - beatmap.Beatmap.AudioLeadIn); + double firstHitObjectTime = beatmap.HitObjects.First().StartTime; + if (beatmap.AudioLeadIn > 0) + time = Math.Min(time, firstHitObjectTime - beatmap.AudioLeadIn); return time; } @@ -117,14 +115,15 @@ namespace osu.Game.Screens.Play /// /// Skip forward to the next valid skip point. /// - public void Skip() + /// true to skip as close to gameplay as possible, or false to skip only to the next valid skip point. + public void Skip(bool fullLength = false) { - if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME) + if (GameplayClock.CurrentTime > GameplayStartTime - MINIMUM_SKIP_TIME) return; - double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME; + double skipTarget = GameplayStartTime - MINIMUM_SKIP_TIME; - if (StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000) + if (!fullLength && StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000) // double skip exception for storyboards with very long intros skipTarget = 0; @@ -138,7 +137,7 @@ namespace osu.Game.Screens.Play { removeAdjustmentsFromTrack(); - track = new TrackVirtual(beatmap.Track.Length); + track = new TrackVirtual(track.Length); track.Seek(CurrentTime); if (IsRunning) track.Start(); @@ -187,7 +186,8 @@ namespace osu.Game.Screens.Play } else { - Logger.Log($"Playback discrepancy detected ({playbackDiscrepancyCount} of allowed {allowed_playback_discrepancies}): {elapsedGameplayClockTime:N1} vs {elapsedValidationTime:N1}"); + Logger.Log( + $"Playback discrepancy detected ({playbackDiscrepancyCount} of allowed {allowed_playback_discrepancies}): {elapsedGameplayClockTime:N1} vs {elapsedValidationTime:N1}"); } elapsedValidationTime = null; @@ -229,9 +229,8 @@ namespace osu.Game.Screens.Play removeAdjustmentsFromTrack(); } - ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo; + ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.ControlPointInfo; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => track.CurrentAmplitudes; IClock IBeatSyncProvider.Clock => this; - - ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; } } diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index 3a471acba4..18d17c1317 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -5,9 +5,12 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Platform; using osu.Game.Audio; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -31,14 +34,29 @@ namespace osu.Game.Screens.Play OnResume?.Invoke(); }; + private readonly IBindable windowActive = new Bindable(true); + + private float targetVolume => windowActive.Value && State.Value == Visibility.Visible ? 1.0f : 0; + [BackgroundDependencyLoader] - private void load() + private void load(GameHost? host) { AddInternal(pauseLoop = new SkinnableSound(new SampleInfo("Gameplay/pause-loop")) { Looping = true, Volume = { Value = 0 } }); + + if (host != null) + windowActive.BindTo(host.IsActive); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Schedule required because host.IsActive doesn't seem to always run on the update thread. + windowActive.BindValueChanged(_ => Schedule(() => pauseLoop.VolumeTo(targetVolume, 1000, Easing.Out))); } public void StopAllSamples() @@ -53,7 +71,7 @@ namespace osu.Game.Screens.Play { base.PopIn(); - pauseLoop.VolumeTo(1.0f, TRANSITION_DURATION, Easing.InQuint); + pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.InQuint); pauseLoop.Play(); } @@ -61,7 +79,7 @@ namespace osu.Game.Screens.Play { base.PopOut(); - pauseLoop.VolumeTo(0, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); + pauseLoop.VolumeTo(targetVolume, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); } public override bool OnPressed(KeyBindingPressEvent e) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index a762d2ae82..6158118c78 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -15,7 +15,6 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; @@ -35,7 +34,6 @@ using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; -using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Users; @@ -59,6 +57,11 @@ namespace osu.Game.Screens.Play public override bool AllowUserExit => false; // handled by HoldForMenuButton + /// + /// Raised after all gameplay has finished. + /// + public event Action OnShowingResults; + protected override bool PlayExitSound => !isRestarting; protected override UserActivity InitialActivity => new UserActivity.InSoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); @@ -69,6 +72,17 @@ namespace osu.Game.Screens.Play public override bool HideMenuCursorOnNonMouseInput => true; + public override bool RequiresPortraitOrientation + { + get + { + if (!LoadedBeatmapSuccessfully) + return false; + + return DrawableRuleset!.RequiresPortraitOrientation; + } + } + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). @@ -88,8 +102,6 @@ namespace osu.Game.Screens.Play private bool isRestarting; private bool skipExitTransition; - private Bindable mouseWheelDisabled; - private readonly Bindable storyboardReplacesBackground = new Bindable(); public IBindable LocalUserPlaying => localUserPlaying; @@ -104,6 +116,12 @@ namespace osu.Game.Screens.Play /// public IBindable ShowingOverlayComponents = new Bindable(); + /// + /// A flag which can be checked to decide whether we are in a state where settings that affect + /// game balance should be allowed to be applied at the current point in time. + /// + public virtual bool AllowCriticalSettingsAdjustment { get; } = true; + // Should match PlayerLoader for consistency. Cached here for the rare case we push a Player // without the loading screen (one such usage is the skin editor's scene library). [Cached] @@ -127,6 +145,8 @@ namespace osu.Game.Screens.Play public BreakOverlay BreakOverlay; + private LetterboxOverlay letterboxOverlay; + /// /// Whether the gameplay is currently in a break. /// @@ -134,7 +154,7 @@ namespace osu.Game.Screens.Play private BreakTracker breakTracker; - private SkipOverlay skipIntroOverlay; + protected SkipOverlay SkipIntroOverlay { get; private set; } private SkipOverlay skipOutroOverlay; protected ScoreProcessor ScoreProcessor { get; private set; } @@ -228,8 +248,6 @@ namespace osu.Game.Screens.Play return; } - mouseWheelDisabled = config.GetBindable(OsuSetting.MouseDisableWheel); - if (game != null) gameActive.BindTo(game.IsActive); @@ -251,7 +269,10 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(HealthProcessor); - InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); + InternalChildren = new Drawable[] + { + GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime), + }; AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); @@ -263,9 +284,16 @@ namespace osu.Game.Screens.Play Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; - dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard)); + dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard, PlayingState)); var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); + GameplayClockContainer.Add(new GameplayScrollWheelHandling()); + + // needs to exist in frame stable content, but is used by underlay layers so make sure assigned early. + breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor) + { + Breaks = Beatmap.Value.Beatmap.Breaks + }; // 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. @@ -282,7 +310,7 @@ namespace osu.Game.Screens.Play Children = new[] { // underlay and gameplay should have access to the skinning sources. - createUnderlayComponents(), + createUnderlayComponents(Beatmap.Value), createGameplayComponents(Beatmap.Value) } }, @@ -323,11 +351,15 @@ namespace osu.Game.Screens.Play } dependencies.CacheAs(DrawableRuleset.FrameStableClock); + dependencies.CacheAs(DrawableRuleset.FrameStableClock); + + letterboxOverlay.Clock = DrawableRuleset.FrameStableClock; + letterboxOverlay.ProcessCustomClock = false; // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. - failAnimationContainer.Add(createOverlayComponents(Beatmap.Value)); + failAnimationContainer.Add(createOverlayComponents()); if (!DrawableRuleset.AllowGameplayOverlays) { @@ -392,20 +424,26 @@ namespace osu.Game.Screens.Play IsBreakTime.BindTo(breakTracker.IsBreakTime); IsBreakTime.BindValueChanged(onBreakTimeChanged, true); - - loadLeaderboard(); } protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); - private Drawable createUnderlayComponents() + private Drawable createUnderlayComponents(WorkingBeatmap working) { var container = new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both }, + DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) + { + RelativeSizeAxes = Axes.Both + }, + letterboxOverlay = new LetterboxOverlay + { + BreakTracker = breakTracker, + Alpha = working.Beatmap.LetterboxInBreaks ? 1 : 0, + }, new KiaiGameplayFountains(), }, }; @@ -423,15 +461,12 @@ namespace osu.Game.Screens.Play ScoreProcessor, HealthProcessor, new ComboEffects(ScoreProcessor), - breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor) - { - Breaks = working.Beatmap.Breaks - } + breakTracker, }), } }; - private Drawable createOverlayComponents(IWorkingBeatmap working) + private Drawable createOverlayComponents() { var container = new Container { @@ -439,7 +474,7 @@ namespace osu.Game.Screens.Play Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), - HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard) + HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods) { HoldToQuit = { @@ -457,7 +492,7 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre }, - BreakOverlay = new BreakOverlay(working.Beatmap.LetterboxInBreaks, ScoreProcessor) + BreakOverlay = new BreakOverlay(ScoreProcessor) { Clock = DrawableRuleset.FrameStableClock, ProcessCustomClock = false, @@ -465,10 +500,10 @@ namespace osu.Game.Screens.Play }, // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), - skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) + SkipIntroOverlay = CreateSkipOverlay(DrawableRuleset.GameplayStartTime).With(o => { - RequestSkip = performUserRequestedSkip - }, + o.RequestSkip = RequestIntroSkip; + }), skipOutroOverlay = new SkipOverlay(GameplayState.Storyboard.LatestEventTime ?? 0) { RequestSkip = () => progressToResults(false), @@ -487,13 +522,15 @@ namespace osu.Game.Screens.Play if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays) { - skipIntroOverlay.Expire(); + SkipIntroOverlay.Expire(); skipOutroOverlay.Expire(); } return container; } + protected virtual SkipOverlay CreateSkipOverlay(double startTime) => new SkipOverlay(startTime); + private void onBreakTimeChanged(ValueChangedEvent isBreakTime) { updateGameplayState(); @@ -666,13 +703,22 @@ namespace osu.Game.Screens.Play return true; } - private void performUserRequestedSkip() + protected virtual void RequestIntroSkip() + { + PerformIntroSkip(); + } + + /// + /// Skip forward to the next valid skip point. + /// + /// true to skip as close to gameplay as possible, or false to skip only to the next valid skip point. + protected void PerformIntroSkip(bool fullLength = false) { // user requested skip // disable sample playback to stop currently playing samples and perform skip samplePlaybackDisabled.Value = true; - (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(); + (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(fullLength); // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state updateSampleDisabledState(); @@ -840,6 +886,7 @@ namespace osu.Game.Screens.Play // This player instance may already be in the process of exiting. return; + OnShowingResults?.Invoke(); this.Push(CreateResults(prepareScoreForDisplayTask.GetResultSafely())); }, Time.Current + delay, 50); @@ -894,60 +941,8 @@ namespace osu.Game.Screens.Play }); } - protected override bool OnScroll(ScrollEvent e) - { - // During pause, allow global volume adjust regardless of settings. - if (GameplayClockContainer.IsPaused.Value) - return false; - - // Block global volume adjust if the user has asked for it (special case when holding "Alt"). - return mouseWheelDisabled.Value && !e.AltPressed; - } - - #region Gameplay leaderboard - - protected readonly Bindable LeaderboardExpandedState = new BindableBool(); - - private void loadLeaderboard() - { - HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState()); - LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); - - var gameplayLeaderboard = CreateGameplayLeaderboard(); - - if (gameplayLeaderboard != null) - { - LoadComponentAsync(gameplayLeaderboard, leaderboard => - { - if (!LoadedBeatmapSuccessfully) - return; - - leaderboard.Expanded.BindTo(LeaderboardExpandedState); - - AddLeaderboardToHUD(leaderboard); - }); - } - } - - [CanBeNull] - protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null; - - protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard); - - private void updateLeaderboardExpandedState() => - LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value; - - #endregion - #region Fail Logic - /// - /// Invoked when gameplay has permanently failed. - /// - protected virtual void OnFail() - { - } - protected FailOverlay FailOverlay { get; private set; } private FailAnimationContainer failAnimationContainer; @@ -961,50 +956,57 @@ namespace osu.Game.Screens.Play if (!CheckModsAllowFailure()) return false; - if (Configuration.AllowFailAnimation) - { - Debug.Assert(!GameplayState.HasFailed); - Debug.Assert(!GameplayState.HasPassed); - Debug.Assert(!GameplayState.HasQuit); - - GameplayState.HasFailed = true; - - updateGameplayState(); - - // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) - // could process an extra frame after the GameplayClock is stopped. - // In such cases we want the fail state to precede a user triggered pause. - if (PauseOverlay.State.Value == Visibility.Visible) - PauseOverlay.Hide(); - - bool restartOnFail = GameplayState.Mods.OfType().Any(m => m.RestartOnFail); - if (!restartOnFail) - failAnimationContainer.Start(); - - // Failures can be triggered either by a judgement, or by a mod. - // - // For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received - // the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above). - // - // A schedule here ensures that any lingering judgements from the current frame are applied before we - // finalise the score as "failed". - Schedule(() => - { - ScoreProcessor.FailScore(Score.ScoreInfo); - OnFail(); - - if (restartOnFail) - Restart(true); - }); - } - else - { - ScoreProcessor.FailScore(Score.ScoreInfo); - } - + PerformFail(); return true; } + /// + /// Called when the player is determined to have failed. + /// + protected virtual void PerformFail() + { + Debug.Assert(!GameplayState.HasFailed); + Debug.Assert(!GameplayState.HasPassed); + Debug.Assert(!GameplayState.HasQuit); + + GameplayState.HasFailed = true; + + updateGameplayState(); + + // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) + // could process an extra frame after the GameplayClock is stopped. + // In such cases we want the fail state to precede a user triggered pause. + if (PauseOverlay.State.Value == Visibility.Visible) + PauseOverlay.Hide(); + + bool restartOnFail = GameplayState.Mods.OfType().Any(m => m.RestartOnFail); + if (!restartOnFail) + failAnimationContainer.Start(); + + // Failures can be triggered either by a judgement, or by a mod. + // + // For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received + // the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above). + // + // A schedule here ensures that any lingering judgements from the current frame are applied before we + // finalise the score as "failed". + Schedule(() => + { + ConcludeFailedScore(Score); + + if (restartOnFail) + Restart(true); + }); + } + + /// + /// Performs last operations on the supplied before this is definitively exited due to failing. + /// + protected virtual void ConcludeFailedScore(Score score) + { + ScoreProcessor.FailScore(score.ScoreInfo); + } + /// /// Invoked when the fail animation has finished. /// @@ -1032,7 +1034,7 @@ namespace osu.Game.Screens.Play private double? lastPauseActionTime; protected bool PauseCooldownActive => - lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + PauseCooldownDuration; + PlayingState.Value == LocalUserPlayingState.Playing && lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + PauseCooldownDuration; /// /// A set of conditionals which defines whether the current game state and configuration allows for @@ -1055,7 +1057,7 @@ namespace osu.Game.Screens.Play // already resuming && !IsResuming; - public bool Pause() + public virtual bool Pause() { if (!pausingSupportedByCurrentState) return false; @@ -1162,7 +1164,7 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Reset(startClock: true); if (Configuration.AutomaticallySkipIntro) - skipIntroOverlay.SkipWhenReady(); + SkipIntroOverlay.SkipWhenReady(); } public override void OnSuspending(ScreenTransitionEvent e) @@ -1268,11 +1270,7 @@ namespace osu.Game.Screens.Play /// /// The to be displayed in the results screen. /// The . - protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) - { - AllowRetry = true, - ShowUserStatistics = true, - }; + protected abstract ResultsScreen CreateResults(ScoreInfo score); private void fadeOut() { diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index 466a691118..f529859bfb 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -15,12 +15,6 @@ namespace osu.Game.Screens.Play /// public bool ShowResults { get; set; } = true; - /// - /// Whether the fail animation / screen should be triggered on failing. - /// If false, the score will still be marked as failed but gameplay will continue. - /// - public bool AllowFailAnimation { get; set; } = true; - /// /// Whether the player should be allowed to trigger a restart. /// @@ -42,8 +36,8 @@ namespace osu.Game.Screens.Play public bool AutomaticallySkipIntro { get; set; } /// - /// Whether the gameplay leaderboard should always be shown (usually in a contracted state). + /// Whether the gameplay leaderboard should be shown. /// - public bool AlwaysShowLeaderboard { get; set; } + public bool ShowLeaderboard { get; set; } } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 20985c20e0..57159afd22 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -8,6 +8,7 @@ using ManagedBass.Fx; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -25,12 +26,14 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input; using osu.Game.Localisation; +using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Overlays.Volume; using osu.Game.Performance; -using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.Play.PlayerSettings; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; using osu.Game.Users; using osu.Game.Utils; @@ -53,7 +56,10 @@ namespace osu.Game.Screens.Play public override bool? AllowGlobalTrackControl => false; - public override float BackgroundParallaxAmount => quickRestart ? 0 : 1; + // this makes the game stay in portrait mode when restarting gameplay rather than switching back to landscape. + public override bool RequiresPortraitOrientation => CurrentPlayer?.RequiresPortraitOrientation == true; + + public override float BackgroundParallaxAmount => QuickRestart ? 0 : 1; // Here because IsHovered will not update unless we do so. public override bool HandlePositionalInput => true; @@ -79,8 +85,6 @@ namespace osu.Game.Screens.Play private FillFlowContainer disclaimers = null!; private OsuScrollContainer settingsScroll = null!; - private Bindable lastScore = null!; - private Bindable showStoryboards = null!; private bool backgroundBrightnessReduction; @@ -94,6 +98,8 @@ namespace osu.Game.Screens.Play private Box? quickRestartBlackLayer; + private ScheduledDelegate? quickRestartBackButtonRestore; + [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); @@ -142,6 +148,7 @@ namespace osu.Game.Screens.Play private bool playerConsumed; private LogoTrackingContainer content = null!; + private IDisposable? logoTracking; private bool hideOverlays; @@ -153,7 +160,7 @@ namespace osu.Game.Screens.Play private PlayerLoaderDisclaimer? epilepsyWarning; - private bool quickRestart; + protected bool QuickRestart { get; private set; } private IDisposable? highPerformanceSession; @@ -172,6 +179,9 @@ namespace osu.Game.Screens.Play [Resolved] private IHighPerformanceSessionManager? highPerformanceSessionManager { get; set; } + [Resolved] + private LeaderboardManager? leaderboardManager { get; set; } + public PlayerLoader(Func createPlayer) { this.createPlayer = createPlayer; @@ -182,14 +192,13 @@ namespace osu.Game.Screens.Play { muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); - lastScore = sessionStatics.GetBindable(Static.LastLocalUserScore); - showStoryboards = config.GetBindable(OsuSetting.ShowStoryboard); const float padding = 25; InternalChildren = new Drawable[] { + new GlobalScrollAdjustsVolume(), (content = new LogoTrackingContainer { Anchor = Anchor.Centre, @@ -267,6 +276,23 @@ namespace osu.Game.Screens.Play showStoryboards.BindValueChanged(val => epilepsyWarning?.FadeTo(val.NewValue ? 1 : 0, 250, Easing.OutQuint), true); epilepsyWarning?.FinishTransforms(true); + + // this re-fetch has two purposes: + // - is a safety against potential unexpected screen transitions, making sure that the leaderboard + // displayed during gameplay definitely matches the beatmap and ruleset being played + // (as the solo gameplay leaderboard provider uses the global leaderboard manager to populate itself) + // - the sort mode is not specified and defaults to `Score` which is good because gameplay leaderboards only support sorting by score. + // this may change at some point in the future, at which point specifying a sort mode should be considered. + refetchLeaderboard(force: false); + } + + private void refetchLeaderboard(bool force) + { + leaderboardManager?.FetchWithCriteria(new LeaderboardCriteria( + Beatmap.Value.BeatmapInfo, + Ruleset.Value, + leaderboardManager?.CurrentCriteria?.Scope ?? BeatmapLeaderboardScope.Global, + leaderboardManager?.CurrentCriteria?.ExactMods), force); } #region Screen handling @@ -302,8 +328,7 @@ namespace osu.Game.Screens.Play Debug.Assert(CurrentPlayer != null); - highPerformanceSession?.Dispose(); - highPerformanceSession = null; + endHighPerformance(); // prepare for a retry. CurrentPlayer = null; @@ -349,10 +374,7 @@ namespace osu.Game.Screens.Play BackgroundBrightnessReduction = false; Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); - highPerformanceSession?.Dispose(); - highPerformanceSession = null; - - lastScore.Value = null; + endHighPerformance(); return base.OnExiting(e); } @@ -371,7 +393,7 @@ namespace osu.Game.Screens.Play logo.ScaleTo(new Vector2(0.15f), duration, Easing.OutQuint); - if (quickRestart) + if (QuickRestart) { logo.Delay(quick_restart_initial_delay) .FadeIn(350); @@ -382,21 +404,26 @@ namespace osu.Game.Screens.Play Scheduler.AddDelayed(() => { if (this.IsCurrentScreen()) - content.StartTracking(logo, resuming ? 0 : 500, Easing.InOutExpo); + logoTracking = content.StartTracking(logo, resuming ? 0 : 500, Easing.InOutExpo); }, resuming ? 0 : 250); } protected override void LogoExiting(OsuLogo logo) { base.LogoExiting(logo); - content.StopTracking(); + + logoTracking?.Dispose(); + logoTracking = null; + osuLogo = null; } protected override void LogoSuspending(OsuLogo logo) { base.LogoSuspending(logo); - content.StopTracking(); + + logoTracking?.Dispose(); + logoTracking = null; logo .FadeOut(CONTENT_OUT_DURATION / 2, Easing.OutQuint) @@ -416,7 +443,7 @@ namespace osu.Game.Screens.Play // We need to perform this check here rather than in OnHover as any number of children of VisualSettings // may also be handling the hover events. - if (inputManager.HoveredDrawables.Contains(VisualSettings) || quickRestart) + if (inputManager.HoveredDrawables.Contains(VisualSettings) || QuickRestart) { // Preview user-defined background dim and blur when hovered on the visual settings panel. ApplyToBackground(b => @@ -455,7 +482,7 @@ namespace osu.Game.Screens.Play return; CurrentPlayer = createPlayer(); - CurrentPlayer.Configuration.AutomaticallySkipIntro |= quickRestart; + CurrentPlayer.Configuration.AutomaticallySkipIntro |= QuickRestart; CurrentPlayer.RestartCount = restartCount++; CurrentPlayer.PrepareLoaderForRestart = prepareForRestart; @@ -472,16 +499,21 @@ namespace osu.Game.Screens.Play private void prepareForRestart(bool quickRestartRequested) { - quickRestart = quickRestartRequested; + QuickRestart = quickRestartRequested; hideOverlays = true; ValidForResume = true; + // when retrying, it is desired to refetch the global state leaderboard so that the user's previous score can show up on the leaderboard, if it needs to. + // that said, only do this when the user is *not* quick-retrying. + // this avoids the quick retry becoming longer than it needs to (because an extra API request has to complete before gameplay can start), + // and if the user is quick-retrying, their last score is most likely not important for global leaderboards, or the user won't care. + refetchLeaderboard(force: !quickRestartRequested); } private void contentIn(double delayBeforeSideDisplays = 0) { MetadataInfo.Loading = true; - if (quickRestart) + if (QuickRestart) { BackButtonVisibility.Value = false; @@ -504,7 +536,8 @@ namespace osu.Game.Screens.Play .ScaleTo(1) .FadeInFromZero(500, Easing.OutQuint); - this.Delay(quick_restart_initial_delay).Schedule(() => BackButtonVisibility.Value = true); + quickRestartBackButtonRestore?.Cancel(); + quickRestartBackButtonRestore = Scheduler.AddDelayed(() => BackButtonVisibility.Value = true, quick_restart_initial_delay); } else { @@ -541,7 +574,8 @@ namespace osu.Game.Screens.Play protected virtual void ContentOut() { // Ensure the logo is no longer tracking before we scale the content - content.StopTracking(); + logoTracking?.Dispose(); + logoTracking = null; content.ScaleTo(0.7f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); content.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) @@ -567,6 +601,8 @@ namespace osu.Game.Screens.Play private void pushWhenLoaded() { + Debug.Assert(ThreadSafety.IsUpdateThread); + if (!this.IsCurrentScreen()) return; if (!readyForPush) @@ -589,7 +625,9 @@ namespace osu.Game.Screens.Play scheduledPushPlayer = Scheduler.AddDelayed(() => { // ensure that once we have reached this "point of no return", readyForPush will be false for all future checks (until a new player instance is prepared). - var consumedPlayer = consumePlayer(); + Player consumedPlayer = consumePlayer(); + + consumedPlayer.OnShowingResults += endHighPerformance; ContentOut(); @@ -616,7 +654,7 @@ namespace osu.Game.Screens.Play }, // When a quick restart is activated, the metadata content will display some time later if it's taking too long. // To avoid it appearing too briefly, if it begins to fade in let's induce a standard delay. - quickRestart && content.Alpha == 0 ? 0 : 500); + QuickRestart && content.Alpha == 0 ? 0 : 500); } private void cancelLoad() @@ -625,6 +663,14 @@ namespace osu.Game.Screens.Play scheduledPushPlayer = null; } + private void endHighPerformance() + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + highPerformanceSession?.Dispose(); + highPerformanceSession = null; + } + #region Disposal protected override void Dispose(bool isDisposing) @@ -637,8 +683,8 @@ namespace osu.Game.Screens.Play DisposalTask = LoadTask?.ContinueWith(_ => CurrentPlayer?.Dispose()); } + // This is only a failsafe; should be disposed more immediately by `endHighPerformance` call. highPerformanceSession?.Dispose(); - highPerformanceSession = null; } #endregion @@ -668,8 +714,6 @@ namespace osu.Game.Screens.Play private partial class MutedNotification : SimpleNotification { - public override bool IsImportant => true; - public MutedNotification() { Text = NotificationsStrings.GameVolumeTooLow; @@ -701,6 +745,10 @@ namespace osu.Game.Screens.Play #region Low battery warning + /// + /// This is intentionally higher than 20%, which is usually when OS level notifications + /// interrupt the active application to warn the user. + /// private const double low_battery_threshold = 0.25; private Bindable batteryWarningShownOnce = null!; @@ -721,8 +769,6 @@ namespace osu.Game.Screens.Play private partial class BatteryWarningNotification : SimpleNotification { - public override bool IsImportant => true; - public BatteryWarningNotification() { Text = NotificationsStrings.BatteryLow; diff --git a/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs index 3c79721590..9ff90f6fef 100644 --- a/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Play.PlayerSettings private readonly PlayerCheckbox beatmapHitsoundsToggle; public AudioSettings() - : base("Audio Settings") + : base(PlayerSettingsOverlayStrings.AudioSettingsTitle) { Children = new Drawable[] { diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index 74b887481f..e2337a4e0e 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -15,7 +15,9 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -36,6 +38,10 @@ namespace osu.Game.Screens.Play.PlayerSettings { public Bindable ReferenceScore { get; } = new Bindable(); + private Bindable lastAppliedScore { get; } = new Bindable(); + + private readonly Bindable autoAdjustBeatmapOffset = new Bindable(); + public BindableDouble Current { get; } = new BindableDouble { MinValue = -50, @@ -58,17 +64,21 @@ namespace osu.Game.Screens.Play.PlayerSettings private Player? player { get; set; } [Resolved] - private IGameplayClock? gameplayClock { get; set; } + private SettingsOverlay? settings { get; set; } - private double lastPlayAverage; + // the last play graph is relative to the offset at the point of the last play, so we need to factor that out for some usages. + private double adjustmentSinceLastPlay => lastPlayBeatmapOffset - Current.Value; + + private double lastPlayMedian; + private double lastPlayUnstableRate; private double lastPlayBeatmapOffset; private HitEventTimingDistributionGraph? lastPlayGraph; - - private SettingsButton? useAverageButton; - + private SettingsButton? calibrateFromLastPlayButton; private IDisposable? beatmapOffsetSubscription; - private Task? realmWriteTask; + private ScoreInfo? lastValidScore; + + private bool allowOffsetAdjust => player?.AllowCriticalSettingsAdjustment != false; public BeatmapOffsetControl() { @@ -100,6 +110,13 @@ namespace osu.Game.Screens.Play.PlayerSettings }; } + [BackgroundDependencyLoader] + private void load(SessionStatics statics, OsuConfigManager config) + { + statics.BindWith(Static.LastAppliedOffsetScore, lastAppliedScore); + config.BindWith(OsuSetting.AutomaticallyAdjustBeatmapOffset, autoAdjustBeatmapOffset); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -112,7 +129,11 @@ namespace osu.Game.Screens.Play.PlayerSettings // At the point we reach here, it's not guaranteed that all realm writes have taken place (there may be some in-flight). // We are only aware of writes that originated from our own flow, so if we do see one that's active we can avoid handling the feedback value arriving. if (realmWriteTask == null) + { + Current.Disabled = false; Current.Value = val; + Current.Disabled = allowOffsetAdjust; + } if (realmWriteTask?.IsCompleted == true) { @@ -125,57 +146,104 @@ namespace osu.Game.Screens.Play.PlayerSettings ReferenceScore.BindValueChanged(scoreChanged, true); } + public bool OnPressed(KeyBindingPressEvent e) + { + // To match stable, this should adjust by 5 ms, or 1 ms when holding alt. + // But that is hard to make work with global actions due to the operating mode. + // Let's use the more precise as a default for now. + const double amount = 1; + + switch (e.Action) + { + case GlobalAction.IncreaseOffset: + if (!Current.Disabled) + Current.Value += amount; + return true; + + case GlobalAction.DecreaseOffset: + if (!Current.Disabled) + Current.Value -= amount; + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + protected override void Update() + { + base.Update(); + + bool allow = allowOffsetAdjust; + + if (calibrateFromLastPlayButton != null) + { + double suggestedOffset = computeSuggestedOffset(lastPlayMedian, lastPlayUnstableRate, lastPlayBeatmapOffset); + calibrateFromLastPlayButton.Enabled.Value = allow && !Precision.AlmostEquals(suggestedOffset, Current.Value, Current.Precision / 2); + } + + Current.Disabled = !allow; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + beatmapOffsetSubscription?.Dispose(); + } + private void currentChanged(ValueChangedEvent offset) { - Scheduler.AddOnce(updateOffset); + // Negative is applied here because the play graph is considering a hit offset, not track (as we currently use for clocks). + lastPlayGraph?.UpdateOffset(-adjustmentSinceLastPlay); - void updateOffset() + // Calibration button may be hidden due to automatic offset adjustment, but it should be visible when the user manually adjusts their offset away from the applied suggestion. + calibrateFromLastPlayButton?.Show(); + + // This is intentionally not scheduled as the offset may be changed while the control is hidden and cannot process its scheduler. + // This is the case when auto-adjustment is enabled and the offset is adjusted while the player is quick-retrying. + writeOffsetToBeatmap(); + } + + private void writeOffsetToBeatmap() + { + // ensure the previous write has completed. ignoring performance concerns, if we don't do this, the async writes could be out of sequence. + if (realmWriteTask?.IsCompleted == false) { - // the last play graph is relative to the offset at the point of the last play, so we need to factor that out. - double adjustmentSinceLastPlay = lastPlayBeatmapOffset - Current.Value; - - // Negative is applied here because the play graph is considering a hit offset, not track (as we currently use for clocks). - lastPlayGraph?.UpdateOffset(-adjustmentSinceLastPlay); - - // ensure the previous write has completed. ignoring performance concerns, if we don't do this, the async writes could be out of sequence. - if (realmWriteTask?.IsCompleted == false) - { - Scheduler.AddOnce(updateOffset); - return; - } - - if (useAverageButton != null) - { - useAverageButton.Enabled.Value = !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2); - } - - realmWriteTask = realm.WriteAsync(r => - { - var setInfo = r.Find(beatmap.Value.BeatmapSetInfo.ID); - - if (setInfo == null) // only the case for tests. - return; - - // Apply to all difficulties in a beatmap set for now (they generally always share timing). - foreach (var b in setInfo.Beatmaps) - { - BeatmapUserSettings userSettings = b.UserSettings; - double val = Current.Value; - - if (userSettings.Offset != val) - userSettings.Offset = val; - } - }); + Scheduler.AddOnce(writeOffsetToBeatmap); + return; } + + realmWriteTask = realm.WriteAsync(r => + { + var setInfo = r.Find(beatmap.Value.BeatmapSetInfo.ID); + + if (setInfo == null) // only the case for tests. + return; + + // Apply to all difficulties in a beatmap set if they have the same audio + // (they generally always share timing). + foreach (var b in setInfo.Beatmaps) + { + BeatmapUserSettings userSettings = b.UserSettings; + double val = Current.Value; + + if (userSettings.Offset != val && b.AudioEquals(beatmap.Value.BeatmapInfo)) + userSettings.Offset = val; + } + }); } private void scoreChanged(ValueChangedEvent score) { - referenceScoreContainer.Clear(); - if (score.NewValue == null) return; + if (score.NewValue.Equals(lastAppliedScore.Value)) + return; + if (!score.NewValue.BeatmapInfo.AsNonNull().Equals(beatmap.Value.BeatmapInfo)) return; @@ -184,7 +252,19 @@ namespace osu.Game.Screens.Play.PlayerSettings var hitEvents = score.NewValue.HitEvents; - if (!(hitEvents.CalculateAverageHitError() is double average)) + if (hitEvents.CalculateMedianHitError() is not double median) + return; + + if (hitEvents.CalculateUnstableRate()?.Result is not double unstableRate) + return; + + // affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event, + // i.e. a user input that the user had to *time to the track*, + // i.e. one that it *makes sense to use* when doing anything with timing and offsets. + bool hasEnoughUsableEvents = hitEvents.Count(HitEventExtensions.AffectsUnstableRate) >= 50; + + // If we already have an old score with enough hit events and the new score doesn't have enough, continue displaying the old one rather than showing the user "play too short" message. + if (lastValidScore != null && !hasEnoughUsableEvents) return; referenceScoreContainer.Children = new Drawable[] @@ -195,10 +275,7 @@ namespace osu.Game.Screens.Play.PlayerSettings }, }; - // affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event, - // i.e. an user input that the user had to *time to the track*, - // i.e. one that it *makes sense to use* when doing anything with timing and offsets. - if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 10) + if (!hasEnoughUsableEvents) { referenceScoreContainer.AddRange(new Drawable[] { @@ -214,10 +291,12 @@ namespace osu.Game.Screens.Play.PlayerSettings return; } - lastPlayAverage = average; + lastValidScore = score.NewValue!; + lastPlayMedian = median; + lastPlayUnstableRate = unstableRate; lastPlayBeatmapOffset = Current.Value; - LinkFlowContainer globalOffsetText; + LinkFlowContainer offsetText; referenceScoreContainer.AddRange(new Drawable[] { @@ -226,79 +305,58 @@ namespace osu.Game.Screens.Play.PlayerSettings RelativeSizeAxes = Axes.X, Height = 50, }, - new AverageHitError(hitEvents), - useAverageButton = new SettingsButton + new AverageHitError(hitEvents) { FontSize = OsuFont.Style.Caption1.Size }, + calibrateFromLastPlayButton = new SettingsButton { Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay, - Action = () => Current.Value = lastPlayBeatmapOffset - lastPlayAverage, - Enabled = { Value = !Precision.AlmostEquals(lastPlayAverage, 0, Current.Precision / 2) } + Action = () => + { + if (!Current.Disabled) + applySuggestedOffset(); + }, }, - globalOffsetText = new LinkFlowContainer + offsetText = new LinkFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, } }); - if (settings != null) + if (autoAdjustBeatmapOffset.Value && !Current.Disabled) { - globalOffsetText.AddText("You can also "); - globalOffsetText.AddLink("adjust the global offset", () => settings.ShowAtControl()); - globalOffsetText.AddText(" based off this play."); - } - } + bool offsetChanged = applySuggestedOffset(); - [Resolved] - private SettingsOverlay? settings { get; set; } + calibrateFromLastPlayButton.Hide(); - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - beatmapOffsetSubscription?.Dispose(); - } - - public bool OnPressed(KeyBindingPressEvent e) - { - // General limitations to ensure players don't do anything too weird. - // These match stable for now. - if (player is SubmittingPlayer) - { - // TODO: the blocking conditions should probably display a message. - if (player?.IsBreakTime.Value == false && gameplayClock?.CurrentTime - gameplayClock?.StartTime > 10000) - return false; - - if (gameplayClock?.IsPaused.Value == true) - return false; + if (offsetChanged) + { + offsetText.AddText($"Beatmap offset was adjusted to {Current.Value.ToStandardFormattedString(1)} ms.", t => t.Font = OsuFont.Style.Caption1); + offsetText.NewParagraph(); + } } - // To match stable, this should adjust by 5 ms, or 1 ms when holding alt. - // But that is hard to make work with global actions due to the operating mode. - // Let's use the more precise as a default for now. - const double amount = 1; - - switch (e.Action) - { - case GlobalAction.IncreaseOffset: - Current.Value += amount; - return true; - - case GlobalAction.DecreaseOffset: - Current.Value -= amount; - return true; - } - - return false; + offsetText.AddText("You can also ", t => t.Font = OsuFont.Style.Caption2); + offsetText.AddLink("adjust the global offset", () => settings?.ShowAtControl(), creationParameters: t => t.Font = OsuFont.Style.Caption2); + offsetText.AddText(" based off this play.", t => t.Font = OsuFont.Style.Caption2); } - public void OnReleased(KeyBindingReleaseEvent e) + private bool applySuggestedOffset() { + double lastOffset = Current.Value; + + Current.Value = computeSuggestedOffset(lastPlayMedian, lastPlayUnstableRate, lastPlayBeatmapOffset); + lastAppliedScore.Value = lastValidScore; + + return !Precision.AlmostEquals(Current.Value, lastOffset, Current.Precision / 2); } public static LocalisableString GetOffsetExplanatoryText(double offset) { - return offset == 0 - ? LocalisableString.Interpolate($@"{offset:0.0} ms") - : LocalisableString.Interpolate($@"{offset:0.0} ms {getEarlyLateText(offset)}"); + string formatOffset = offset.ToStandardFormattedString(1); + + return formatOffset == "0" + ? LocalisableString.Interpolate($@"{formatOffset} ms") + : LocalisableString.Interpolate($@"{formatOffset} ms {getEarlyLateText(offset)}"); LocalisableString getEarlyLateText(double value) { @@ -310,6 +368,23 @@ namespace osu.Game.Screens.Play.PlayerSettings } } + private static double computeSuggestedOffset(double median, double unstableRate, double currentOffset) + { + const double ur_adjustment_cutoff = 90; + const double exponential_factor = -0.0116; + + double offsetAdjustment = median; + + if (unstableRate >= ur_adjustment_cutoff) + { + // A demonstrative graph of this algorithm is embedded in https://github.com/ppy/osu/discussions/30521. + // This ultimately prevents scores with high unstable rate from suggesting potentially invalid offsets. + offsetAdjustment *= Math.Exp(exponential_factor * (unstableRate - ur_adjustment_cutoff)); + } + + return currentOffset - offsetAdjustment; + } + private partial class OffsetSliderBar : PlayerSliderBar { protected override Drawable CreateControl() => new CustomSliderBar(); diff --git a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs index 1387e01305..9c9f31e903 100644 --- a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs @@ -12,7 +12,7 @@ namespace osu.Game.Screens.Play.PlayerSettings public partial class InputSettings : PlayerSettingsGroup { public InputSettings() - : base("Input Settings") + : base(PlayerSettingsOverlayStrings.InputSettingsTitle) { } diff --git a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs index b3d07421ed..be84d498fa 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Play.PlayerSettings private IconButton pausePlay = null!; public PlaybackSettings() - : base("playback") + : base(PlayerSettingsOverlayStrings.PlaybackTitle) { } @@ -138,7 +138,7 @@ namespace osu.Game.Screens.Play.PlayerSettings { rateSlider = new PlayerSliderBar { - LabelText = "Playback speed", + LabelText = PlayerSettingsOverlayStrings.PlaybackSpeed, Current = UserPlaybackRate, }, multiplierText = new OsuSpriteText diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs index 838106e198..0f9a00dfd2 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs @@ -2,13 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Overlays; namespace osu.Game.Screens.Play.PlayerSettings { public partial class PlayerSettingsGroup : SettingsToolboxGroup { - public PlayerSettingsGroup(string title) + public PlayerSettingsGroup(LocalisableString title) : base(title) { } diff --git a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs index ff857ddb12..6a09e627c1 100644 --- a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Play.PlayerSettings private readonly PlayerCheckbox beatmapColorsToggle; public VisualSettings() - : base("Visual Settings") + : base(PlayerSettingsOverlayStrings.VisualSettingsTitle) { Children = new Drawable[] { diff --git a/osu.Game/Screens/Play/ReplayFailIndicator.cs b/osu.Game/Screens/Play/ReplayFailIndicator.cs new file mode 100644 index 0000000000..769a84dce4 --- /dev/null +++ b/osu.Game/Screens/Play/ReplayFailIndicator.cs @@ -0,0 +1,174 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using ManagedBass.Fx; +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.Shapes; +using osu.Framework.Platform; +using osu.Game.Audio; +using osu.Game.Audio.Effects; +using osu.Game.Beatmaps; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Skinning; +using osuTK; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Play +{ + public partial class ReplayFailIndicator : CompositeDrawable + { + public Action? GoToResults { get; init; } + + private readonly GameplayClockContainer gameplayClockContainer; + private readonly BindableDouble trackFreq = new BindableDouble(1); + private readonly BindableDouble volumeAdjustment = new BindableDouble(1); + + private Track track = null!; + private SkinnableSound failSample = null!; + private AudioFilter failLowPassFilter = null!; + private AudioFilter failHighPassFilter = null!; + private Container content = null!; + + private double? failTime; + + // relied on to make arbitrary seeks / rewinding work pretty well out-of-the-box, leveraging custom clock and absolute transform sequences + public override bool RemoveCompletedTransforms => false; + + public ReplayFailIndicator(GameplayClockContainer gameplayClockContainer) + { + Clock = this.gameplayClockContainer = gameplayClockContainer; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, AudioManager audio, IBindable beatmap, GameHost host) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + + track = beatmap.Value.Track; + + RoundedButton goToResultsButton; + + InternalChildren = new Drawable[] + { + failSample = new SkinnableSound(new SampleInfo(@"Gameplay/failsound")), + failLowPassFilter = new AudioFilter(audio.TrackMixer), + failHighPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), + content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 20, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray3, + Alpha = 0.8f, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(20), + Spacing = new Vector2(15), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Title, + Text = ReplayFailIndicatorStrings.ReplayFailed, + }, + goToResultsButton = new RoundedButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 150, + Text = ReplayFailIndicatorStrings.GoToResults, + Action = GoToResults, + } + } + } + } + } + }; + + // every single component here is fine being synced to the gameplay clock... + // except the "go to results" button, which starts having hover animations synced to the audio track + // which is something that we don't want. + // it is maybe probably possible to restructure the drawable hierarchy here to remove the button from under the gameplay clock, + // but it would resort in uglier and more complicated drawable code. + // thus, resort to the escape hatch extension method to ensure the button specifically still runs on the game update clock. + goToResultsButton.ApplyGameWideClock(host); + + track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + track.AddAdjustment(AdjustableProperty.Frequency, trackFreq); + } + + public void Display() + { + failTime = Clock.CurrentTime; + + using (BeginAbsoluteSequence(failTime.Value)) + { + // intentionally shorter than the actual fail animation + const double audio_sweep_duration = 1000; + + content.FadeInFromZero(200, Easing.OutQuint); + this.ScaleTo(1.1f, audio_sweep_duration, Easing.OutElasticHalf); + this.TransformBindableTo(trackFreq, 0, audio_sweep_duration); + this.TransformBindableTo(volumeAdjustment, 0.5); + failHighPassFilter.CutoffTo(300); + failLowPassFilter.CutoffTo(300, audio_sweep_duration, Easing.OutCubic); + } + } + + private bool failSamplePlaybackInitiated; + + protected override void Update() + { + base.Update(); + + // the playback of the fail sample is the one thing that cannot be easily written using rewindable transforms and such. + // this part needs to be hardcoded in update to work. + if (gameplayClockContainer.GetTrueGameplayRate() > 0 && Time.Current >= failTime && !failSamplePlaybackInitiated) + { + failSamplePlaybackInitiated = true; + failSample.Play(); + } + + if (Time.Current < failTime && failSamplePlaybackInitiated) + { + failSamplePlaybackInitiated = false; + failSample.Stop(); + } + } + + protected override void Dispose(bool isDisposing) + { + failSample.Stop(); + failSample.Dispose(); + track.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 0c125264a1..1c583609d9 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -1,24 +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 disable - using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; 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.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.Play @@ -30,29 +29,47 @@ namespace osu.Game.Screens.Play private readonly Func, Score> createScore; - private readonly bool replayIsFailedScore; + [Cached(typeof(IGameplayLeaderboardProvider))] + private readonly SoloGameplayLeaderboardProvider leaderboardProvider = new SoloGameplayLeaderboardProvider(); - protected override UserActivity InitialActivity => new UserActivity.WatchingReplay(Score.ScoreInfo); + protected override UserActivity? InitialActivity => + // score may be null if LoadedBeatmapSuccessfully is false. + Score == null ? null : new UserActivity.WatchingReplay(Score.ScoreInfo); + + private bool isAutoplayPlayback => GameplayState.Mods.OfType().Any(); + + private double? lastFrameTime; + + private ReplayFailIndicator? failIndicator; + private PlaybackSettings? playbackSettings; - // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) protected override bool CheckModsAllowFailure() { - if (!replayIsFailedScore && !GameplayState.Mods.OfType().Any()) - return false; + // autoplay should be able to fail if the beatmap is not humanly beatable + if (isAutoplayPlayback) + return base.CheckModsAllowFailure(); - return base.CheckModsAllowFailure(); + // non-autoplay replays should be able to fail, but only after they've exhausted their frames. + // note that the rank isn't checked here - that's because it is generally unreliable. + // stable replays, as well as lazer replays recorded prior to https://github.com/ppy/osu/pull/28058, + // do not even *contain* the user's rank. + // not to mention possible gameplay mechanics changes that could make a replay fail sooner than it really should. + if (GameplayClockContainer.CurrentTime >= lastFrameTime) + return base.CheckModsAllowFailure(); + + return false; } - public ReplayPlayer(Score score, PlayerConfiguration configuration = null) + public ReplayPlayer(Score score, PlayerConfiguration? configuration = null) : this((_, _) => score, configuration) { - replayIsFailedScore = score.ScoreInfo.Rank == ScoreRank.F; } - public ReplayPlayer(Func, Score> createScore, PlayerConfiguration configuration = null) + public ReplayPlayer(Func, Score> createScore, PlayerConfiguration? configuration = null) : base(configuration) { this.createScore = createScore; + Configuration.ShowLeaderboard = true; } /// @@ -71,7 +88,9 @@ namespace osu.Game.Screens.Play if (!LoadedBeatmapSuccessfully) return; - var playbackSettings = new PlaybackSettings + AddInternal(leaderboardProvider); + + playbackSettings = new PlaybackSettings { Depth = float.MaxValue, Expanded = { BindTarget = config.GetBindable(OsuSetting.ReplayPlaybackControlsExpanded) } @@ -81,11 +100,23 @@ namespace osu.Game.Screens.Play playbackSettings.UserPlaybackRate.BindTo(master.UserPlaybackRate); HUDOverlay.PlayerSettingsOverlay.AddAtStart(playbackSettings); + AddInternal(failIndicator = new ReplayFailIndicator(GameplayClockContainer) + { + GoToResults = () => + { + if (!this.IsCurrentScreen()) + return; + + ValidForResume = false; + this.Push(new SoloResultsScreen(Score.ScoreInfo)); + } + }); } protected override void PrepareReplay() { DrawableRuleset?.SetReplayScore(Score); + lastFrameTime = Score.Replay.Frames.LastOrDefault()?.Time; } protected override Score CreateScore(IBeatmap beatmap) => createScore(beatmap, Mods.Value); @@ -93,19 +124,18 @@ namespace osu.Game.Screens.Play // Don't re-import replay scores as they're already present in the database. protected override Task ImportScore(Score score) => Task.CompletedTask; - public readonly BindableList LeaderboardScores = new BindableList(); - - protected override GameplayLeaderboard CreateGameplayLeaderboard() => - new SoloGameplayLeaderboard(Score.ScoreInfo.User) - { - AlwaysVisible = { Value = true }, - Scores = { BindTarget = LeaderboardScores } - }; - - protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score); + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) + { + // Only show the relevant button otherwise things look silly. + AllowWatchingReplay = !isAutoplayPlayback, + AllowRetry = isAutoplayPlayback, + }; public bool OnPressed(KeyBindingPressEvent e) { + if (!LoadedBeatmapSuccessfully) + return false; + switch (e.Action) { case GlobalAction.StepReplayBackward: @@ -117,11 +147,11 @@ namespace osu.Game.Screens.Play return true; case GlobalAction.SeekReplayBackward: - SeekInDirection(-5); + SeekInDirection(-5 * (float)playbackSettings!.UserPlaybackRate.Value); return true; case GlobalAction.SeekReplayForward: - SeekInDirection(5); + SeekInDirection(5 * (float)playbackSettings!.UserPlaybackRate.Value); return true; case GlobalAction.TogglePauseReplay: @@ -160,5 +190,37 @@ namespace osu.Game.Screens.Play public void OnReleased(KeyBindingReleaseEvent e) { } + + protected override void PerformFail() + { + // base logic intentionally suppressed - we have our own custom fail interaction + ScoreProcessor.FailScore(Score.ScoreInfo); + failIndicator!.Display(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + stopAllAudioEffects(); + base.OnSuspending(e); + } + + public override bool OnExiting(ScreenExitEvent e) + { + // safety against filters or samples from the indicator playing long after the screen is exited + failIndicator?.RemoveAndDisposeImmediately(); + return base.OnExiting(e); + } + + private void stopAllAudioEffects() + { + // safety against filters or samples from the indicator playing long after the screen is exited + failIndicator?.RemoveAndDisposeImmediately(); + + if (GameplayClockContainer is MasterGameplayClockContainer master) + { + playbackSettings?.UserPlaybackRate.UnbindFrom(master.UserPlaybackRate); + master.UserPlaybackRate.SetDefault(); + } + } } } diff --git a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs index 74ee7e1868..4e4d35bd30 100644 --- a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs +++ b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; +using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -35,6 +36,9 @@ namespace osu.Game.Screens.Play if (beatmapId <= 0) return null; + if (Beatmap.Value.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified) + return null; + if (!Ruleset.Value.IsLegacyRuleset()) return null; diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index be8517d9a0..700ea2e532 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -38,20 +38,21 @@ namespace osu.Game.Screens.Play private readonly double startTime; public Action RequestSkip; + + protected FadeContainer FadingContent { get; private set; } + private Button button; private ButtonContainer buttonContainer; private Circle remainingTimeBox; - private FadeContainer fadeContainer; private double displayTime; - private bool isClickable; private bool skipQueued; [Resolved] private IGameplayClock gameplayClock { get; set; } - internal bool IsButtonVisible => fadeContainer.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; + internal bool IsButtonVisible => FadingContent.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; /// @@ -77,7 +78,7 @@ namespace osu.Game.Screens.Play InternalChild = buttonContainer = new ButtonContainer { RelativeSizeAxes = Axes.Both, - Child = fadeContainer = new FadeContainer + Child = FadingContent = new FadeContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -107,13 +108,13 @@ namespace osu.Game.Screens.Play public override void Hide() { base.Hide(); - fadeContainer.Hide(); + FadingContent.Hide(); } public override void Show() { base.Show(); - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); } protected override void LoadComplete() @@ -136,7 +137,7 @@ namespace osu.Game.Screens.Play RequestSkip?.Invoke(); }; - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); } /// @@ -183,7 +184,7 @@ namespace osu.Game.Screens.Play protected override bool OnMouseMove(MouseMoveEvent e) { if (isClickable && !e.HasAnyButtonPressed) - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); return base.OnMouseMove(e); } diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index f4cf2da364..1e9222e40a 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -5,28 +5,33 @@ using System; using System.Diagnostics; -using System.Threading.Tasks; -using osu.Framework.Bindables; +using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Scoring; -using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.Play { public partial class SoloPlayer : SubmittingPlayer { - public SoloPlayer() - : this(null) - { - } + [Cached(typeof(IGameplayLeaderboardProvider))] + private readonly SoloGameplayLeaderboardProvider leaderboardProvider = new SoloGameplayLeaderboardProvider(); - protected SoloPlayer(PlayerConfiguration configuration = null) + public SoloPlayer([CanBeNull] PlayerConfiguration configuration = null) : base(configuration) { + Configuration.ShowLeaderboard = true; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(leaderboardProvider); } protected override APIRequest CreateTokenRequest() @@ -37,38 +42,22 @@ namespace osu.Game.Screens.Play if (beatmapId <= 0) return null; + if (Beatmap.Value.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified) + return null; + if (!Ruleset.Value.IsLegacyRuleset()) return null; return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash); } - public readonly BindableList LeaderboardScores = new BindableList(); - - protected override GameplayLeaderboard CreateGameplayLeaderboard() => - new SoloGameplayLeaderboard(Score.ScoreInfo.User) - { - AlwaysVisible = { Value = false }, - Scores = { BindTarget = LeaderboardScores } - }; - protected override bool ShouldExitOnTokenRetrievalFailure(Exception exception) => false; - protected override Task ImportScore(Score score) - { - // Before importing a score, stop binding the leaderboard with its score source. - // This avoids a case where the imported score may cause a leaderboard refresh - // (if the leaderboard's source is local). - LeaderboardScores.UnbindBindings(); - - return base.ImportScore(score); - } - protected override APIRequest CreateSubmissionRequest(Score score, long token) { - IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo; + IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo!; - Debug.Assert(beatmap!.OnlineID > 0); + Debug.Assert(beatmap.OnlineID > 0); return new SubmitSoloScoreRequest(score.ScoreInfo, token, beatmap.OnlineID); } diff --git a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs index be83a4c6b5..16b1ff7ccc 100644 --- a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs @@ -6,6 +6,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Screens; using osu.Game.Online.Spectator; using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.Play @@ -14,10 +15,13 @@ namespace osu.Game.Screens.Play { private readonly Score score; + [Cached(typeof(IGameplayLeaderboardProvider))] + private SoloGameplayLeaderboardProvider leaderboardProvider = new SoloGameplayLeaderboardProvider(); + protected override UserActivity InitialActivity => new UserActivity.SpectatingUser(Score.ScoreInfo); public SoloSpectatorPlayer(Score score) - : base(score, new PlayerConfiguration { AllowUserInteraction = false }) + : base(score, new PlayerConfiguration { AllowUserInteraction = false, ShowLeaderboard = true }) { this.score = score; } @@ -26,6 +30,8 @@ namespace osu.Game.Screens.Play private void load() { SpectatorClient.OnUserBeganPlaying += userBeganPlaying; + + AddInternal(leaderboardProvider); } public override bool OnExiting(ScreenExitEvent e) @@ -45,6 +51,26 @@ namespace osu.Game.Screens.Play }); } + #region Fail handling + + protected override bool CheckModsAllowFailure() + { + if (!allowFail) + return false; + + return base.CheckModsAllowFailure(); + } + + private bool allowFail; + + /// + /// Should be called when it is apparent that the player being spectated has failed. + /// This will subsequently stop blocking the fail screen from displaying (usually done out of safety). + /// + public void AllowFail() => allowFail = true; + + #endregion + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Play/SoloSpectatorScreen.cs b/osu.Game/Screens/Play/SoloSpectatorScreen.cs index 269bc3bb92..e54cde4b0a 100644 --- a/osu.Game/Screens/Play/SoloSpectatorScreen.cs +++ b/osu.Game/Screens/Play/SoloSpectatorScreen.cs @@ -30,7 +30,6 @@ using osuTK; namespace osu.Game.Screens.Play { - [Cached(typeof(IPreviewTrackOwner))] public partial class SoloSpectatorScreen : SpectatorScreen, IPreviewTrackOwner { [Resolved] @@ -183,7 +182,7 @@ namespace osu.Game.Screens.Play { if (this.GetChildScreen() is SpectatorPlayerLoader loader) { - if (loader.GetChildScreen() is SpectatorPlayer player) + if (loader.GetChildScreen() is SoloSpectatorPlayer player) { player.AllowFail(); resetStartState(); diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index b2ac946642..22c966e0af 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -23,16 +23,6 @@ namespace osu.Game.Screens.Play private readonly Score score; - protected override bool CheckModsAllowFailure() - { - if (!allowFail) - return false; - - return base.CheckModsAllowFailure(); - } - - private bool allowFail; - protected SpectatorPlayer(Score score, PlayerConfiguration? configuration = null) : base(configuration) { @@ -66,12 +56,6 @@ namespace osu.Game.Screens.Play }, true); } - /// - /// Should be called when it is apparent that the player being spectated has failed. - /// This will subsequently stop blocking the fail screen from displaying (usually done out of safety). - /// - public void AllowFail() => allowFail = true; - protected override void StartGameplay() { base.StartGameplay(); diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 24c5b2c3d4..06f1a9c530 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -21,6 +21,7 @@ using osu.Game.Online.Rooms; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { @@ -152,7 +153,7 @@ namespace osu.Game.Screens.Play Logger.Log($"Please ensure that you are using the latest version of the official game releases.\n\n{whatWillHappen}", level: LogLevel.Important); break; - case @"invalid beatmap_hash": + case @"invalid or missing beatmap_hash": Logger.Log($"This beatmap does not match the online version. Please update or redownload it.\n\n{whatWillHappen}", level: LogLevel.Important); break; @@ -185,6 +186,24 @@ namespace osu.Game.Screens.Play /// Whether gameplay should be immediately exited as a result. Returning false allows the gameplay session to continue. Defaults to true. protected virtual bool ShouldExitOnTokenRetrievalFailure(Exception exception) => true; + public override bool AllowCriticalSettingsAdjustment + { + get + { + // General limitations to ensure players don't do anything too weird. + // These match stable for now. + + // TODO: the blocking conditions should probably display a message. + if (!IsBreakTime.Value && GameplayClockContainer.CurrentTime - GameplayClockContainer.GameplayStartTime > 10000) + return false; + + if (GameplayClockContainer.IsPaused.Value) + return false; + + return base.AllowCriticalSettingsAdjustment; + } + } + protected override async Task PrepareScoreForResultsAsync(Score score) { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); @@ -215,27 +234,38 @@ namespace osu.Game.Screens.Play spectatorClient.BeginPlaying(token, GameplayState, Score); } - protected override void OnFail() + public override bool Pause() { - base.OnFail(); + bool wasPaused = GameplayClockContainer.IsPaused.Value; - submitFromFailOrQuit(); + bool paused = base.Pause(); + + if (!wasPaused && paused) + Score.ScoreInfo.Pauses.Add((int)Math.Round(GameplayClockContainer.CurrentTime)); + + return paused; + } + + protected override void ConcludeFailedScore(Score score) + { + base.ConcludeFailedScore(score); + submitFromFailOrQuit(score); } public override bool OnExiting(ScreenExitEvent e) { bool exiting = base.OnExiting(e); - submitFromFailOrQuit(); + submitFromFailOrQuit(Score); statics.SetValue(Static.LastLocalUserScore, Score?.ScoreInfo.DeepClone()); return exiting; } - private void submitFromFailOrQuit() + private void submitFromFailOrQuit(Score score) { if (LoadedBeatmapSuccessfully) { // compare: https://github.com/ppy/osu/blob/ccf1acce56798497edfaf92d3ece933469edcf0a/osu.Game/Screens/Play/Player.cs#L848-L851 - var scoreCopy = Score.DeepClone(); + var scoreCopy = score.DeepClone(); Task.Run(async () => { @@ -284,6 +314,13 @@ namespace osu.Game.Screens.Play return Task.CompletedTask; } + // zero scores should also never be submitted. + if (score.ScoreInfo.TotalScore == 0) + { + Logger.Log("Zero score, skipping score submission"); + return Task.CompletedTask; + } + // mind the timing of this. // once `scoreSubmissionSource` is created, it is presumed that submission is taking place in the background, // so all exceptional circumstances that would disallow submission must be handled above. @@ -316,5 +353,11 @@ namespace osu.Game.Screens.Play api.Queue(request); return scoreSubmissionSource.Task; } + + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) + { + AllowRetry = true, + IsLocalPlay = true, + }; } } diff --git a/osu.Game/Screens/Ranking/CollectionPopover.cs b/osu.Game/Screens/Ranking/CollectionPopover.cs index ffc448d7a9..8ecee85c3f 100644 --- a/osu.Game/Screens/Ranking/CollectionPopover.cs +++ b/osu.Game/Screens/Ranking/CollectionPopover.cs @@ -10,6 +10,7 @@ using osu.Game.Collections; using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; namespace osu.Game.Screens.Ranking { @@ -59,7 +60,7 @@ namespace osu.Game.Screens.Ranking .AsEnumerable() .Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmapInfo)).Cast().ToList(); - collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, () => manageCollectionsDialog?.Show())); + collectionItems.Add(new OsuMenuItem(CommonStrings.Manage, MenuItemType.Standard, () => manageCollectionsDialog?.Show())); return collectionItems.ToArray(); } diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index cfb6465e62..fbc0fd8a70 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -11,13 +11,15 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; @@ -67,7 +69,7 @@ namespace osu.Game.Screens.Ranking.Contracted Colour = Color4.Black.Opacity(0.25f), Type = EdgeEffectType.Shadow, Radius = 1, - Offset = new Vector2(0, 4) + Offset = new Vector2(0, 2) }, Children = new Drawable[] { @@ -100,18 +102,16 @@ namespace osu.Game.Screens.Ranking.Contracted CornerRadius = 20, EdgeEffect = new EdgeEffectParameters { - Colour = Color4.Black.Opacity(0.25f), + Colour = Color4.Black.Opacity(0.15f), Type = EdgeEffectType.Shadow, Radius = 8, - Offset = new Vector2(0, 4), + Offset = new Vector2(0, 1), } }, - new OsuSpriteText + new ClickableUsername(score.User) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = score.RealmUser.Username, - Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold) }, new FillFlowContainer { @@ -134,14 +134,33 @@ namespace osu.Game.Screens.Ranking.Contracted createStatistic(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, $"{score.Accuracy.FormatAccuracy()}"), } }, - new ModFlowDisplay + new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Current = { Value = score.Mods }, - IconScale = 0.5f, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(3), + ChildrenEnumerable = + [ + new DifficultyIcon(score.BeatmapInfo!, score.Ruleset) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(20), + TooltipType = DifficultyIconTooltipType.Extended, + Margin = new MarginPadding { Right = 2 } + }, + .. + score.Mods.AsOrdered().Select(m => new ModIcon(m) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(0.3f), + Margin = new MarginPadding { Top = -6 } + }) + ] } } } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index 319a87fdfc..f6cf71d8a6 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -91,6 +90,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private readonly ScoreInfo score; + [Resolved] + private ResultsScreen? resultsScreen { get; set; } + private CircularProgress accuracyCircle = null!; private GradedCircles gradedCircles = null!; private Container badges = null!; @@ -101,7 +103,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private PoolableSkinnableSample? badgeMaxSound; private PoolableSkinnableSample? swooshUpSound; private PoolableSkinnableSample? rankImpactSound; - private PoolableSkinnableSample? rankApplauseSound; private readonly Bindable tickPlaybackRate = new Bindable(); @@ -197,15 +198,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy if (withFlair) { - var applauseSamples = new List { applauseSampleName }; - if (score.Rank >= ScoreRank.B) - // when rank is B or higher, play legacy applause sample on legacy skins. - applauseSamples.Insert(0, @"applause"); - AddRangeInternal(new Drawable[] { rankImpactSound = new PoolableSkinnableSample(new SampleInfo(impactSampleName)), - rankApplauseSound = new PoolableSkinnableSample(new SampleInfo(applauseSamples.ToArray())), scoreTickSound = new PoolableSkinnableSample(new SampleInfo(@"Results/score-tick")), badgeTickSound = new PoolableSkinnableSample(new SampleInfo(@"Results/badge-dink")), badgeMaxSound = new PoolableSkinnableSample(new SampleInfo(@"Results/badge-dink-max")), @@ -333,16 +328,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy }); const double applause_pre_delay = 545f; - const double applause_volume = 0.8f; using (BeginDelayedSequence(applause_pre_delay)) - { - Schedule(() => - { - rankApplauseSound!.VolumeTo(applause_volume); - rankApplauseSound!.Play(); - }); - } + Schedule(() => resultsScreen?.PlayApplause(score.Rank)); } } @@ -384,34 +372,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy } } - private string applauseSampleName - { - get - { - switch (score.Rank) - { - default: - case ScoreRank.D: - return @"Results/applause-d"; - - case ScoreRank.C: - return @"Results/applause-c"; - - case ScoreRank.B: - return @"Results/applause-b"; - - case ScoreRank.A: - return @"Results/applause-a"; - - case ScoreRank.S: - case ScoreRank.SH: - case ScoreRank.X: - case ScoreRank.XH: - return @"Results/applause-s"; - } - } - } - private string impactSampleName { get diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs index 76e59b32b8..ce8bd941c2 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs @@ -47,7 +47,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Origin = Anchor.Centre, GlowColour = OsuColour.ForRank(rank), Spacing = new Vector2(-15, 0), - Text = DrawableRank.GetRankName(rank), + Text = DrawableRank.GetRankLetter(rank), Font = OsuFont.Numeric.With(size: 76), UseFullGlyphHeight = false }, @@ -87,7 +87,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Anchor = Anchor.Centre, Origin = Anchor.Centre, Spacing = new Vector2(-15, 0), - Text = DrawableRank.GetRankName(rank), + Text = DrawableRank.GetRankLetter(rank), Font = OsuFont.Numeric.With(size: 76), UseFullGlyphHeight = false, Shadow = false diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index d1dc1a81db..445d219c7f 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -16,6 +14,7 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -41,11 +40,10 @@ namespace osu.Game.Screens.Ranking.Expanded private readonly List statisticDisplays = new List(); - private FillFlowContainer starAndModDisplay; - private RollingCounter scoreCounter; + private RollingCounter scoreCounter = null!; [Resolved] - private ScoreManager scoreManager { get; set; } + private ScoreManager scoreManager { get; set; } = null!; /// /// Creates a new . @@ -64,12 +62,19 @@ namespace osu.Game.Screens.Ranking.Expanded } [BackgroundDependencyLoader] - private void load(BeatmapDifficultyCache beatmapDifficultyCache) + private void load(RealmAccess realmAccess, BeatmapDifficultyCache beatmapDifficultyCache) { var beatmap = score.BeatmapInfo!; var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; string creator = metadata.Author.Username; + StarDifficulty starDifficulty = new StarDifficulty(beatmap.StarRating, 0); + + // In some cases, the beatmap ferried through ScoreInfo actually represents an online beatmap. + // If it isn't, we may be able to compute a more accurate difficulty from the ruleset and mods. + if (realmAccess.Run(r => r.Find(score.BeatmapInfo!.ID)) != null) + starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods).GetResultSafely() ?? starDifficulty; + var topStatistics = new List { new AccuracyStatistic(score.Accuracy), @@ -101,22 +106,7 @@ namespace osu.Game.Screens.Ranking.Expanded Direction = FillDirection.Vertical, Children = new Drawable[] { - new TruncatingSpriteText - { - 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, - }, - new TruncatingSpriteText - { - 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, - }, + new ClickableMetadata(beatmap.OnlineID, metadata), new Container { Anchor = Anchor.TopCentre, @@ -139,12 +129,35 @@ namespace osu.Game.Screens.Ranking.Expanded Alpha = 0, AlwaysPresent = true }, - starAndModDisplay = new FillFlowContainer + new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new StarRatingDisplay(starDifficulty) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + new DifficultyIcon(beatmap, score.Ruleset) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(20), + TooltipType = DifficultyIconTooltipType.Extended, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + ExpansionMode = ExpansionMode.AlwaysExpanded, + Scale = new Vector2(0.5f), + Current = { Value = score.Mods } + } + } }, new FillFlowContainer { @@ -225,29 +238,6 @@ namespace osu.Game.Screens.Ranking.Expanded if (score.Date != default) AddInternal(new PlayedOnText(score.Date)); - - var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely(); - - if (starDifficulty != null) - { - starAndModDisplay.Add(new StarRatingDisplay(starDifficulty.Value) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }); - } - - if (score.Mods.Any()) - { - starAndModDisplay.Add(new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - ExpansionMode = ExpansionMode.AlwaysExpanded, - Scale = new Vector2(0.5f), - Current = { Value = score.Mods } - }); - } } protected override void LoadComplete() @@ -311,5 +301,47 @@ namespace osu.Game.Screens.Ranking.Expanded time.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt")); } } + + internal partial class ClickableMetadata : OsuHoverContainer + { + [Resolved] + private OsuGame? game { get; set; } + + public ClickableMetadata(int beatmapId, IBeatmapMetadataInfo metadata) + { + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TruncatingSpriteText + { + 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, + }, + new TruncatingSpriteText + { + 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, + } + } + }; + + if (beatmapId > 0) + Action = () => game?.ShowBeatmap(beatmapId); + } + } } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs index c834d541eb..b50996154b 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs @@ -8,8 +8,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Users.Drawables; using osuTK; @@ -62,12 +60,10 @@ namespace osu.Game.Screens.Ranking.Expanded CornerExponent = 2.5f, Masking = true, }, - new OsuSpriteText + new ClickableUsername(user) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = user.Username, - Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold) } } }; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index 7d155e32b0..8a84501a17 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -79,6 +79,11 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics Alpha = 0.5f; TooltipText = ResultsScreenStrings.NoPPForUnrankedMods; } + else if (scoreInfo.Rank == ScoreRank.F) + { + Alpha = 0.5f; + TooltipText = ResultsScreenStrings.NoPPForFailedScores; + } else { Alpha = 1f; diff --git a/osu.Game/Screens/Ranking/FavouriteButton.cs b/osu.Game/Screens/Ranking/FavouriteButton.cs index 019b80dde9..7f1c4e82cc 100644 --- a/osu.Game/Screens/Ranking/FavouriteButton.cs +++ b/osu.Game/Screens/Ranking/FavouriteButton.cs @@ -109,6 +109,7 @@ namespace osu.Game.Screens.Ranking Enabled.Value = true; loading.Hide(); + api.LocalUserState.UpdateFavouriteBeatmapSets(); }; favouriteRequest.Failure += e => { diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 507d138d90..8d5e6c05c3 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -17,22 +18,25 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Screens; +using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; -using osu.Game.Online.API; using osu.Game.Online.Placeholders; using osu.Game.Overlays; +using osu.Game.Overlays.Volume; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking.Expanded.Accuracy; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Ranking { + [Cached] public abstract partial class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler { protected const float BACKGROUND_BLUR = 20; @@ -57,15 +61,12 @@ namespace osu.Game.Screens.Ranking private bool skipExitTransition; - [Resolved] - private IAPIProvider api { get; set; } = null!; - protected StatisticsPanel StatisticsPanel { get; private set; } = null!; private Drawable bottomPanel = null!; private Container detachedPanelContainer = null!; - private bool lastFetchCompleted; + private Task lastFetchTask = Task.CompletedTask; /// /// Whether the user can retry the beatmap from the results screen. @@ -78,14 +79,16 @@ namespace osu.Game.Screens.Ranking public bool AllowWatchingReplay { get; init; } = true; /// - /// Whether the user's personal statistics should be shown on the extended statistics panel - /// after clicking the score panel associated with the being presented. - /// Requires to be present. + /// Whether the provided score is for a local user's play. + /// This will trigger elements like the user's ranking to display. /// - public bool ShowUserStatistics { get; init; } + public bool IsLocalPlay { get; init; } private Sample? popInSample; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + protected ResultsScreen(ScoreInfo? score) { Score = score; @@ -119,11 +122,13 @@ namespace osu.Game.Screens.Ranking RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - StatisticsPanel = createStatisticsPanel().With(panel => + new GlobalScrollAdjustsVolume(), + StatisticsPanel = new StatisticsPanel { - panel.RelativeSizeAxes = Axes.Both; - panel.Score.BindTarget = SelectedScore; - }), + RelativeSizeAxes = Axes.Both, + Score = { BindTarget = SelectedScore }, + AchievedScore = IsLocalPlay && Score != null ? Score : null, + }, ScorePanelList = new ScorePanelList { RelativeSizeAxes = Axes.Both, @@ -186,6 +191,8 @@ namespace osu.Game.Screens.Ranking Scheduler.AddDelayed(() => OverlayActivationMode.Value = OverlayActivation.All, shouldFlair ? AccuracyCircle.TOTAL_DURATION + 1000 : 0); } + bool allowHotkeyRetry = false; + if (AllowWatchingReplay) { buttons.Add(new ReplayDownloadButton(SelectedScore.Value) @@ -193,12 +200,22 @@ namespace osu.Game.Screens.Ranking Score = { BindTarget = SelectedScore }, Width = 300 }); + + // for simplicity, only allow this when coming from a replay player where we know the replay is ready to be played. + // + // if we show it in all cases, consider the case where a user comes from song select and potentially has to download + // the replay before it can be played back. it wouldn't flow well with the quick retry in such a case. + allowHotkeyRetry = player is ReplayPlayer; } if (player != null && AllowRetry) { buttons.Add(new RetryButton { Width = 300 }); + allowHotkeyRetry = true; + } + if (allowHotkeyRetry) + { AddInternal(new HotkeyRetryOverlay { Action = () => @@ -222,74 +239,158 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); - var req = FetchScores(fetchScoresCallback); - - if (req != null) - api.Queue(req); - StatisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); + + fetchScores(null); } protected override void Update() { base.Update(); - if (lastFetchCompleted) + if (ScorePanelList.IsScrolledToStart) + fetchScores(-1); + else if (ScorePanelList.IsScrolledToEnd) + fetchScores(1); + } + + #region Applause + + private PoolableSkinnableSample? rankApplauseSound; + + public void PlayApplause(ScoreRank rank) + { + const double applause_volume = 0.8f; + + if (!this.IsCurrentScreen()) + return; + + rankApplauseSound?.Dispose(); + + var applauseSamples = new List(); + + if (rank >= ScoreRank.B) + // when rank is B or higher, play legacy applause sample on legacy skins. + applauseSamples.Insert(0, @"applause"); + + switch (rank) { - APIRequest? nextPageRequest = null; + default: + case ScoreRank.D: + applauseSamples.Add(@"Results/applause-d"); + break; - if (ScorePanelList.IsScrolledToStart) - nextPageRequest = FetchNextPage(-1, fetchScoresCallback); - else if (ScorePanelList.IsScrolledToEnd) - nextPageRequest = FetchNextPage(1, fetchScoresCallback); + case ScoreRank.C: + applauseSamples.Add(@"Results/applause-c"); + break; - if (nextPageRequest != null) - { - lastFetchCompleted = false; - api.Queue(nextPageRequest); - } + case ScoreRank.B: + applauseSamples.Add(@"Results/applause-b"); + break; + + case ScoreRank.A: + applauseSamples.Add(@"Results/applause-a"); + break; + + case ScoreRank.S: + case ScoreRank.SH: + case ScoreRank.X: + case ScoreRank.XH: + applauseSamples.Add(@"Results/applause-s"); + break; } + + LoadComponentAsync(rankApplauseSound = new PoolableSkinnableSample(new SampleInfo(applauseSamples.ToArray())), s => + { + if (!this.IsCurrentScreen() || s != rankApplauseSound) + return; + + AddInternal(rankApplauseSound); + + rankApplauseSound.VolumeTo(applause_volume); + rankApplauseSound.Play(); + }); + } + + #endregion + + /// + /// Fetches the next page of scores in the given direction. + /// + /// The direction, or null to fetch any scores. + private void fetchScores(int? direction) + { + Debug.Assert(direction == null || direction == -1 || direction == 1); + + if (!lastFetchTask.IsCompleted) + return; + + lastFetchTask = Task.Run(async () => + { + ScoreInfo[] scores; + + switch (direction) + { + default: + scores = await FetchScores().ConfigureAwait(false); + break; + + case -1: + case 1: + scores = await FetchNextPage(direction.Value).ConfigureAwait(false); + break; + } + + await addScores(scores).ConfigureAwait(false); + }); } /// /// Performs a fetch/refresh of scores to be displayed. /// - /// A callback which should be called when fetching is completed. Scheduling is not required. - /// An responsible for the fetch operation. This will be queued and performed automatically. - protected virtual APIRequest? FetchScores(Action> scoresCallback) => null; + protected virtual Task FetchScores() => Task.FromResult([]); /// - /// Performs a fetch of the next page of scores. This is invoked every frame until a non-null is returned. + /// Performs a fetch of the next page of scores. This is invoked every frame. /// /// The fetch direction. -1 to fetch scores greater than the current start of the list, and 1 to fetch scores lower than the current end of the list. - /// A callback which should be called when fetching is completed. Scheduling is not required. - /// An responsible for the fetch operation. This will be queued and performed automatically. - protected virtual APIRequest? FetchNextPage(int direction, Action> scoresCallback) => null; + protected virtual Task FetchNextPage(int direction) => Task.FromResult([]); - /// - /// Creates the to be used to display extended information about scores. - /// - private StatisticsPanel createStatisticsPanel() + private Task addScores(ScoreInfo[] scores) { - return ShowUserStatistics && Score != null - ? new UserStatisticsPanel(Score) - : new StatisticsPanel(); + var tcs = new TaskCompletionSource(); + + Schedule(() => + { + foreach (var s in scores) + { + var panel = ScorePanelList.AddScore(s); + if (detachedPanel != null) + panel.Alpha = 0; + } + + // allow a frame for scroll container to adjust its dimensions with the added scores before fetching again. + Schedule(() => tcs.SetResult()); + + if (ScorePanelList.IsEmpty) + { + // This can happen if for example a beatmap that is part of a playlist hasn't been played yet. + VerticalScrollContent.Add(new MessagePlaceholder(LeaderboardStrings.NoRecordsYet)); + } + + OnScoresAdded(scores); + }); + + return tcs.Task; } - private void fetchScoresCallback(IEnumerable scores) => Schedule(() => + /// + /// Invoked after online scores are fetched and added to the list. + /// + /// The scores that were added. + protected virtual void OnScoresAdded(ScoreInfo[] scores) { - foreach (var s in scores) - addScore(s); - - // allow a frame for scroll container to adjust its dimensions with the added scores before fetching again. - Schedule(() => lastFetchCompleted = true); - - if (ScorePanelList.IsEmpty) - { - // This can happen if for example a beatmap that is part of a playlist hasn't been played yet. - VerticalScrollContent.Add(new MessagePlaceholder(LeaderboardStrings.NoRecordsYet)); - } - }); + } public override void OnEntering(ScreenTransitionEvent e) { @@ -318,6 +419,8 @@ namespace osu.Game.Screens.Ranking if (!skipExitTransition) this.FadeOut(100); + + rankApplauseSound?.Stop(); return false; } @@ -332,14 +435,6 @@ namespace osu.Game.Screens.Ranking return false; } - private void addScore(ScoreInfo score) - { - var panel = ScorePanelList.AddScore(score); - - if (detachedPanel != null) - panel.Alpha = 0; - } - private ScorePanel? detachedPanel; private void onStatisticsStateChanged(ValueChangedEvent state) @@ -426,12 +521,24 @@ namespace osu.Game.Screens.Ranking { } + protected override bool OnScroll(ScrollEvent e) + { + // Match stable behaviour of only alt-scroll adjusting volume. + // This is the same behaviour as the song selection screen. + if (!e.CurrentState.Keyboard.AltPressed) + return true; + + return base.OnScroll(e); + } + protected partial class VerticalScrollContainer : OsuScrollContainer { protected override Container Content => content; private readonly Container content; + protected override bool OnScroll(ScrollEvent e) => !e.ControlPressed && !e.AltPressed && !e.ShiftPressed && !e.SuperPressed; + public VerticalScrollContainer() { Masking = false; diff --git a/osu.Game/Screens/Ranking/RetryButton.cs b/osu.Game/Screens/Ranking/RetryButton.cs index d977f25323..8b4f3ca14c 100644 --- a/osu.Game/Screens/Ranking/RetryButton.cs +++ b/osu.Game/Screens/Ranking/RetryButton.cs @@ -38,8 +38,6 @@ namespace osu.Game.Screens.Ranking Icon = FontAwesome.Solid.Redo, }, }; - - TooltipText = "retry"; } [BackgroundDependencyLoader] @@ -48,7 +46,14 @@ namespace osu.Game.Screens.Ranking background.Colour = colours.Green; if (player != null) + { + TooltipText = player is ReplayPlayer ? "replay" : "retry"; Action = () => player.Restart(); + } + else + { + TooltipText = "retry"; + } } } } diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index e711bed729..b0e1c89121 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -334,7 +334,7 @@ namespace osu.Game.Screens.Ranking private partial class Scroll : OsuScrollContainer { - public new float Target => base.Target; + public new double Target => base.Target; public Scroll() : base(Direction.Horizontal) @@ -344,7 +344,7 @@ namespace osu.Game.Screens.Ranking /// /// The target that will be scrolled to instantaneously next frame. /// - public float? InstantScrollTarget; + public double? InstantScrollTarget; protected override void UpdateAfterChildren() { diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 33b4bf976b..5e0095611c 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -1,49 +1,164 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Game.Beatmaps; +using osu.Framework.Bindables; +using osu.Framework.Logging; using osu.Game.Extensions; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Rulesets; +using osu.Game.Online.Leaderboards; using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.Ranking { public partial class SoloResultsScreen : ResultsScreen { - private GetScoresRequest? getScoreRequest; + private readonly IBindable globalScores = new Bindable(); + + private TaskCompletionSource? requestTaskSource; [Resolved] - private RulesetStore rulesets { get; set; } = null!; + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private LeaderboardManager leaderboardManager { get; set; } = null!; public SoloResultsScreen(ScoreInfo score) : base(score) { } - protected override APIRequest? FetchScores(Action> scoresCallback) + protected override void LoadComplete() { - Debug.Assert(Score != null); - - if (Score.BeatmapInfo!.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending) - return null; - - getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); - getScoreRequest.Success += r => scoresCallback.Invoke(r.Scores.Where(s => !s.MatchesOnlineID(Score)).Select(s => s.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); - return getScoreRequest; + base.LoadComplete(); + globalScores.BindTo(leaderboardManager.Scores); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - getScoreRequest?.Cancel(); + if (requestTaskSource?.Task.IsCompleted == false) + requestTaskSource.SetCanceled(); + } + + protected override async Task FetchScores() + { + Debug.Assert(Score != null); + + // sort mode intentionally omitted to default to score - results screen only supports sorting by score, so don't pass any other to avoid confusion + var criteria = new LeaderboardCriteria( + Score.BeatmapInfo!, + Score.Ruleset, + leaderboardManager.CurrentCriteria?.Scope ?? BeatmapLeaderboardScope.Global, + leaderboardManager.CurrentCriteria?.ExactMods + ); + + Debug.Assert(requestTaskSource == null || requestTaskSource.Task.IsCompleted); + + requestTaskSource = new TaskCompletionSource(); + + globalScores.BindValueChanged(_ => + { + if (globalScores.Value != null && leaderboardManager.CurrentCriteria?.Equals(criteria) == true) + requestTaskSource.TrySetResult(globalScores.Value); + }); + + Schedule(() => leaderboardManager.FetchWithCriteria(criteria, forceRefresh: true)); + + var result = await requestTaskSource.Task.ConfigureAwait(false); + + if (result.FailState != null) + { + Logger.Log($"Failed to fetch scores (beatmap: {Score.BeatmapInfo}, ruleset: {Score.Ruleset}): {result.FailState}"); + return []; + } + + var clonedScores = result.AllScores.Select(s => s.DeepClone()).ToArray(); + + List sortedScores = []; + + foreach (var clonedScore in clonedScores) + { + // ensure that we do not double up on the score being presented here. + // additionally, ensure that the reference that ends up in `sortedScores` is the `Score` reference specifically. + // this simplifies handling later. + if (clonedScore.Equals(Score) || clonedScore.MatchesOnlineID(Score)) + { + // this is a precautionary guard that prevents `Score` from appearing multiple times in the list. + // that can occur in rare cases wherein two local scores have the same online ID but different replay contents + // (this is possible e.g. in cases of client-side vs server-side recorded replays, see https://github.com/ppy/osu-server-spectator/issues/193) + if (sortedScores.Contains(Score)) + continue; + + Score.Position = clonedScore.Position; + sortedScores.Add(Score); + } + else + { + bool isOnlineLeaderboard = criteria.Scope != BeatmapLeaderboardScope.Local; + bool presentingLocalUserScore = Score.UserID == api.LocalUser.Value.OnlineID; + bool presentedLocalUserScoreIsBetter = presentingLocalUserScore && clonedScore.UserID == api.LocalUser.Value.OnlineID && clonedScore.TotalScore < Score.TotalScore; + + if (isOnlineLeaderboard && presentedLocalUserScoreIsBetter) + continue; + + sortedScores.Add(clonedScore); + } + } + + // if we haven't encountered a match for the presented score, we still need to attach it. + // note that the above block ensuring that the `Score` reference makes it in here makes this valid to write in this way. + if (!sortedScores.Contains(Score)) + sortedScores.Add(Score); + + sortedScores = sortedScores.OrderByTotalScore().ToList(); + + int delta = 0; + bool isPartialLeaderboard = result.IsPartial; + + for (int i = 0; i < sortedScores.Count; i++) + { + var sortedScore = sortedScores[i]; + + // see `SoloGameplayLeaderboardProvider.sort()` for another place that does the same thing with slight deviations + // if this code is changed, that code should probably be changed as well + + if (!isPartialLeaderboard) + sortedScore.Position = i + 1; + else + { + if (ReferenceEquals(sortedScore, Score) && sortedScore.Position == null) + { + int? previousScorePosition = i > 0 ? sortedScores[i - 1].Position : 0; + int? nextScorePosition = i < result.TopScores.Count - 1 ? sortedScores[i + 1].Position : null; + + if (previousScorePosition != null && nextScorePosition != null && previousScorePosition + 1 == nextScorePosition) + { + sortedScore.Position = previousScorePosition + 1; + delta += 1; + } + else + sortedScore.Position = null; + } + else + sortedScore.Position += delta; + } + } + + // there's a non-zero chance that the `Score.Position` was mutated above, + // but that is not actually coupled to `ScorePosition` of the relevant score panel in any way, + // so ensure that the drawable panel also receives the updated position. + // note that this is valid to do precisely because we ensured `Score` was in `sortedScores` earlier. + ScorePanelList.GetPanelForScore(Score).ScorePosition.Value = Score.Position; + + sortedScores.Remove(Score); + return sortedScores.ToArray(); } } } diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs index d8de1b07b5..280227baea 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs @@ -19,10 +19,23 @@ namespace osu.Game.Screens.Ranking.Statistics /// protected string Value { - set => this.value.Text = value; + set => valueText.Text = value; } - private readonly OsuSpriteText value; + /// + /// The font size preferred for the displayed texts. + /// + public float FontSize + { + set + { + nameText.Font = nameText.Font.With(size: value); + valueText.Font = valueText.Font.With(size: value); + } + } + + private readonly OsuSpriteText nameText; + private readonly OsuSpriteText valueText; /// /// Creates a new simple statistic item. @@ -37,14 +50,14 @@ namespace osu.Game.Screens.Ranking.Statistics AddRange(new[] { - new OsuSpriteText + nameText = new OsuSpriteText { Text = Name, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE) }, - value = new OsuSpriteText + valueText = new OsuSpriteText { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItemContainer.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItemContainer.cs index 6e18ae1fe4..8caf8d66b5 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticItemContainer.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItemContainer.cs @@ -1,15 +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.Color4Extensions; 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.Sprites; -using osuTK; namespace osu.Game.Screens.Ranking.Statistics { @@ -53,7 +50,9 @@ namespace osu.Game.Screens.Ranking.Statistics Padding = new MarginPadding(5), Children = new[] { - createHeader(item), + LocalisableString.IsNullOrEmpty(item.Name) + ? Empty() + : new StatisticItemHeader { Text = item.Name }, new Container { RelativeSizeAxes = Axes.X, @@ -66,37 +65,5 @@ namespace osu.Game.Screens.Ranking.Statistics } }; } - - private static Drawable createHeader(StatisticItem item) - { - if (LocalisableString.IsNullOrEmpty(item.Name)) - return Empty(); - - return new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - Height = 20, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Children = new Drawable[] - { - new Circle - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Height = 9, - Width = 4, - Colour = Color4Extensions.FromHex("#00FFAA") - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Text = item.Name, - Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE, weight: FontWeight.SemiBold), - } - } - }; - } } } diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItemHeader.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItemHeader.cs new file mode 100644 index 0000000000..6b496e10dd --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItemHeader.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics +{ + public partial class StatisticItemHeader : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text; + set + { + if (text == value) return; + + text = value; + if (IsLoaded) + spriteText.Text = value; + } + } + + private LocalisableString text; + private OsuSpriteText spriteText = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + Height = 20, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new Circle + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 9, + Width = 4, + Colour = Color4Extensions.FromHex("#00FFAA") + }, + spriteText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = text, + Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE, weight: FontWeight.SemiBold), + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index f9f5254bc2..5c5c814c5b 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -14,11 +14,17 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Placeholders; using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics.User; using osuTK; +using Realms; namespace osu.Game.Screens.Ranking.Statistics { @@ -28,11 +34,24 @@ namespace osu.Game.Screens.Ranking.Statistics public readonly Bindable Score = new Bindable(); + /// + /// The score which was achieved by the local user. + /// If this is set to a non-null score, an component will be displayed showing changes to the local user's ranking and statistics + /// when a statistics update related to this score is received from spectator server. + /// + public ScoreInfo? AchievedScore { get; init; } + protected override bool StartHidden => true; [Resolved] private BeatmapManager beatmapManager { get; set; } = null!; + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + private readonly Container content; private readonly LoadingSpinner spinner; @@ -97,7 +116,7 @@ namespace osu.Game.Screens.Ranking.Statistics bool hitEventsAvailable = newScore.HitEvents.Count != 0; Container container; - var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely()); + var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely()).ToArray(); if (!hitEventsAvailable && statisticItems.All(c => c.RequiresHitEvents)) { @@ -199,8 +218,88 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// The score to create the rows for. /// The beatmap on which the score was set. - protected virtual ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) - => newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap); + protected virtual IEnumerable CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) + { + foreach (var statistic in newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap)) + yield return statistic; + + if (AchievedScore != null + && newScore.UserID > 1 + && newScore.UserID == AchievedScore.UserID + && newScore.OnlineID > 0 + && newScore.OnlineID == AchievedScore.OnlineID) + { + yield return new StatisticItem("Overall Ranking", () => new OverallRanking(newScore) + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + if (newScore.BeatmapInfo!.OnlineID > 0 + && api.IsLoggedIn) + { + string? preventTaggingReason = null; + + // We may want to iterate on the following conditions further in the future + + var localUserScore = AchievedScore ?? realm.Run(r => + r.GetAllLocalScoresForUser(api.LocalUser.Value.Id) + .Filter($@"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0", newScore.BeatmapInfo.ID) + .AsEnumerable() + .OrderByDescending(score => score.Ruleset.MatchesOnlineID(newScore.BeatmapInfo.Ruleset)) + .ThenByDescending(score => score.Rank) + .FirstOrDefault()); + + if (localUserScore == null) + preventTaggingReason = "Play the beatmap to contribute to beatmap tags!"; + else if (localUserScore.Ruleset.OnlineID != newScore.BeatmapInfo!.Ruleset.OnlineID) + preventTaggingReason = "Play the beatmap in its original ruleset to contribute to beatmap tags!"; + else if (localUserScore.Rank < ScoreRank.C) + preventTaggingReason = "Set a better score to contribute to beatmap tags!"; + + if (preventTaggingReason == null) + { + yield return new StatisticItem("Tag the beatmap!", () => new UserTagControl(newScore.BeatmapInfo) + { + Writable = true, + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + else + { + yield return new StatisticItem("Tag the beatmap!", () => new FillFlowContainer + { + Children = new CompositeDrawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE, weight: FontWeight.SemiBold)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.Centre, + Text = preventTaggingReason, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new UserTagControl(newScore.BeatmapInfo) + { + Writable = false, + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(4), + }); + } + } + } protected override bool OnClick(ClickEvent e) { @@ -221,7 +320,10 @@ namespace osu.Game.Screens.Ranking.Statistics this.FadeOut(250, Easing.OutQuint); if (wasOpened) + { popOutSample?.Play(); + this.HidePopover(); // targeted at the user tag control + } } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs index d114bed156..c89e48e78d 100644 --- a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs +++ b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs @@ -21,6 +21,6 @@ namespace osu.Game.Screens.Ranking.Statistics Value = hitEvents.CalculateUnstableRate()?.Result; } - protected override string DisplayValue(double? value) => value == null ? "(not available)" : value.Value.ToString(@"N2"); + protected override string DisplayValue(double? value) => value?.ToString(@"N2") ?? "(not available)"; } } diff --git a/osu.Game/Screens/Ranking/Statistics/User/GlobalRankChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/GlobalRankChangeRow.cs index 0d91d6f8f9..5ffea094cd 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/GlobalRankChangeRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/GlobalRankChangeRow.cs @@ -2,9 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; -using osu.Game.Utils; namespace osu.Game.Screens.Ranking.Statistics.User { @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Ranking.Statistics.User protected override LocalisableString Label => UsersStrings.ShowRankGlobalSimple; protected override LocalisableString FormatCurrentValue(int? current) - => current == null ? string.Empty : current.Value.FormatRank(); + => current?.ToLocalisableString(@"N0") ?? string.Empty; protected override int CalculateDifference(int? previous, int? current, out LocalisableString formattedDifference) { @@ -30,13 +30,13 @@ namespace osu.Game.Screens.Ranking.Statistics.User if (previous == null && current != null) { - formattedDifference = LocalisableString.Interpolate($"+{current.Value.FormatRank()}"); + formattedDifference = LocalisableString.Interpolate($"+{current.Value:N0}"); return 1; } if (previous != null && current == null) { - formattedDifference = LocalisableString.Interpolate($"-{previous.Value.FormatRank()}"); + formattedDifference = LocalisableString.Interpolate($"-{previous.Value:N0}"); return -1; } @@ -46,9 +46,9 @@ namespace osu.Game.Screens.Ranking.Statistics.User int difference = previous.Value - current.Value; if (difference < 0) - formattedDifference = difference.FormatRank(); + formattedDifference = difference.ToLocalisableString(@"N0"); else if (difference > 0) - formattedDifference = LocalisableString.Interpolate($"+{difference.FormatRank()}"); + formattedDifference = LocalisableString.Interpolate($"+{difference:N0}"); else formattedDifference = string.Empty; diff --git a/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs index 9f5afea6f0..9d0a511f5a 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs @@ -5,8 +5,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Scoring; namespace osu.Game.Screens.Ranking.Statistics.User { @@ -14,13 +16,21 @@ namespace osu.Game.Screens.Ranking.Statistics.User { private const float transition_duration = 300; - public Bindable StatisticsUpdate { get; } = new Bindable(); + public Bindable DisplayedUpdate { get; } = new Bindable(); + private readonly IBindable latestGlobalStatisticsUpdate = new Bindable(); + + private readonly ScoreInfo scoreInfo; private LoadingLayer loadingLayer = null!; private GridContainer content = null!; + public OverallRanking(ScoreInfo scoreInfo) + { + this.scoreInfo = scoreInfo; + } + [BackgroundDependencyLoader] - private void load() + private void load(UserStatisticsWatcher? userStatisticsWatcher) { AutoSizeAxes = Axes.Y; AutoSizeEasing = Easing.OutQuint; @@ -55,34 +65,44 @@ namespace osu.Game.Screens.Ranking.Statistics.User { new Drawable[] { - new GlobalRankChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new GlobalRankChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, new SimpleStatisticTable.Spacer(), - new PerformancePointsChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new PerformancePointsChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, }, [], new Drawable[] { - new MaximumComboChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new MaximumComboChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, new SimpleStatisticTable.Spacer(), - new AccuracyChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new AccuracyChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, }, [], new Drawable[] { - new RankedScoreChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new RankedScoreChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, new SimpleStatisticTable.Spacer(), - new TotalScoreChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new TotalScoreChangeRow { StatisticsUpdate = { BindTarget = DisplayedUpdate } }, } } } }; + + if (userStatisticsWatcher != null) + { + latestGlobalStatisticsUpdate.BindTo(userStatisticsWatcher.LatestUpdate); + latestGlobalStatisticsUpdate.BindValueChanged(update => + { + if (update.NewValue?.Score.MatchesOnlineID(scoreInfo) == true) + DisplayedUpdate.Value = update.NewValue; + }, true); + } } protected override void LoadComplete() { base.LoadComplete(); - StatisticsUpdate.BindValueChanged(onUpdateReceived, true); + DisplayedUpdate.BindValueChanged(onUpdateReceived, true); FinishTransforms(true); } diff --git a/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs index c1faf1a3e3..3af1bdb860 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.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 System; using System.Diagnostics; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Ranking.Statistics.User { - public partial class PerformancePointsChangeRow : RankingChangeRow + public partial class PerformancePointsChangeRow : RankingChangeRow { public PerformancePointsChangeRow() - : base(stats => stats.PP) + : base(stats => stats.PP != null ? (int)Math.Round(stats.PP.Value) : null) { } protected override LocalisableString Label => RankingsStrings.StatPerformance; - protected override LocalisableString FormatCurrentValue(decimal? current) + protected override LocalisableString FormatCurrentValue(int? current) => current == null ? string.Empty : LocalisableString.Interpolate($@"{current:N0}pp"); - protected override int CalculateDifference(decimal? previous, decimal? current, out LocalisableString formattedDifference) + protected override int CalculateDifference(int? previous, int? current, out LocalisableString formattedDifference) { if (previous == null && current == null) { diff --git a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs deleted file mode 100644 index 86fed4a9bb..0000000000 --- a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.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.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Extensions; -using osu.Game.Online; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking.Statistics.User; - -namespace osu.Game.Screens.Ranking.Statistics -{ - public partial class UserStatisticsPanel : StatisticsPanel - { - private readonly ScoreInfo achievedScore; - - internal readonly Bindable DisplayedUserStatisticsUpdate = new Bindable(); - - private IBindable latestGlobalStatisticsUpdate = null!; - - public UserStatisticsPanel(ScoreInfo achievedScore) - { - this.achievedScore = achievedScore; - } - - [BackgroundDependencyLoader] - private void load(UserStatisticsWatcher? userStatisticsWatcher) - { - if (userStatisticsWatcher != null) - { - latestGlobalStatisticsUpdate = userStatisticsWatcher.LatestUpdate.GetBoundCopy(); - latestGlobalStatisticsUpdate.BindValueChanged(update => - { - if (update.NewValue?.Score.MatchesOnlineID(achievedScore) == true) - DisplayedUserStatisticsUpdate.Value = update.NewValue; - }, true); - } - } - - protected override ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) - { - var items = base.CreateStatisticItems(newScore, playableBeatmap); - - if (newScore.UserID > 1 - && newScore.UserID == achievedScore.UserID - && newScore.OnlineID > 0 - && newScore.OnlineID == achievedScore.OnlineID) - { - items = items.Append(new StatisticItem("Overall Ranking", () => new OverallRanking - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - StatisticsUpdate = { BindTarget = DisplayedUserStatisticsUpdate } - })).ToArray(); - } - - return items; - } - } -} diff --git a/osu.Game/Screens/Ranking/UserTag.cs b/osu.Game/Screens/Ranking/UserTag.cs new file mode 100644 index 0000000000..9a93df91b5 --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTag.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.Bindables; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.Ranking +{ + public record UserTag + { + public long Id { get; } + public string FullName { get; } + public string? GroupName { get; } + public string DisplayName { get; } + public string Description { get; } + + public BindableInt VoteCount { get; } = new BindableInt(); + public BindableBool Voted { get; } = new BindableBool(); + public BindableBool Updating { get; } = new BindableBool(); + + public UserTag(APITag tag) + { + Id = tag.Id; + FullName = tag.Name; + Description = tag.Description; + + string[] splitName = FullName.Split('/'); + GroupName = splitName.Length > 1 ? splitName[0] : null; + DisplayName = splitName[^1]; + } + } +} diff --git a/osu.Game/Screens/Ranking/UserTagControl.AddTagsPopover.cs b/osu.Game/Screens/Ranking/UserTagControl.AddTagsPopover.cs new file mode 100644 index 0000000000..ed4b46ab64 --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTagControl.AddTagsPopover.cs @@ -0,0 +1,277 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public partial class UserTagControl + { + private partial class AddTagsPopover : OsuPopover + { + private SearchTextBox searchBox = null!; + private SearchContainer searchContainer = null!; + + public BindableDictionary AvailableTags { get; } = new BindableDictionary(); + + public Action? OnSelected { get; set; } + + private CancellationTokenSource? loadCancellationTokenSource; + + [BackgroundDependencyLoader] + private void load() + { + AllowableAnchors = new[] + { + Anchor.TopCentre, + Anchor.BottomCentre, + }; + + Children = new Drawable[] + { + new Container + { + Size = new Vector2(400, 300), + Children = new Drawable[] + { + searchBox = new SearchTextBox + { + HoldFocus = true, + RelativeSizeAxes = Axes.X, + Depth = float.MinValue, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.X, + Y = 40, + Height = 260, + ScrollbarOverlapsContent = false, + Child = searchContainer = new SearchContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 5, Bottom = 10 }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + } + } + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AvailableTags.BindCollectionChanged((_, _) => + { + loadCancellationTokenSource?.Cancel(); + loadCancellationTokenSource = new CancellationTokenSource(); + + LoadComponentsAsync(createItems(AvailableTags.Values), loaded => + { + searchContainer.Clear(); + searchContainer.AddRange(loaded); + }, loadCancellationTokenSource.Token); + }, true); + searchBox.Current.BindValueChanged(_ => searchContainer.SearchTerm = searchBox.Current.Value, true); + } + + private IEnumerable createItems(IEnumerable tags) + { + var grouped = tags.GroupBy(tag => tag.GroupName).OrderBy(group => group.Key); + + foreach (var group in grouped) + { + var drawableGroup = new GroupFlow(group.Key); + + foreach (var tag in group.OrderBy(t => t.FullName)) + drawableGroup.Add(new DrawableAddableTag(tag) { Action = () => OnSelected?.Invoke(tag) }); + + yield return drawableGroup; + } + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + attemptSelect(); + return true; + } + + return false; + } + + private void attemptSelect() + { + var visibleItems = searchContainer.ChildrenOfType().Where(d => d.IsPresent).ToArray(); + + if (visibleItems.Length == 1) + OnSelected?.Invoke(visibleItems.Single().Tag); + } + + private partial class GroupFlow : FillFlowContainer, IFilterable + { + public IEnumerable FilterTerms { get; } + + public bool MatchingFilter + { + set => Alpha = value ? 1 : 0; + } + + public bool FilteringActive + { + set { } + } + + public GroupFlow(string? name) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + Spacing = new Vector2(5); + + Add(new StatisticItemHeader { Text = name ?? "uncategorised" }); + + FilterTerms = name == null ? [] : [name]; + } + } + + private partial class DrawableAddableTag : OsuAnimatedButton, IFilterable + { + public readonly UserTag Tag; + + private Box votedBackground = null!; + private SpriteIcon votedIcon = null!; + + private readonly Bindable voted = new Bindable(); + private readonly BindableBool updating = new BindableBool(); + + private LoadingLayer loadingLayer = null!; + + public DrawableAddableTag(UserTag tag) + { + Tag = tag; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + ScaleOnMouseDown = 0.95f; + + voted.BindTo(Tag.Voted); + updating.BindTo(Tag.Updating); + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray7, + Depth = float.MaxValue, + }, + new Container + { + RelativeSizeAxes = Axes.Y, + Width = 30, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Depth = float.MaxValue, + Children = new Drawable[] + { + votedBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + votedIcon = new SpriteIcon + { + Size = new Vector2(16), + Icon = FontAwesome.Solid.ThumbsUp, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Padding = new MarginPadding(5) { Right = 35 }, + Children = new Drawable[] + { + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(weight: FontWeight.SemiBold)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = Tag.DisplayName, + }, + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = Tag.Description, + } + } + }, + loadingLayer = new LoadingLayer(dimBackground: true), + }); + } + + public IEnumerable FilterTerms => [Tag.FullName, Tag.Description]; + + public bool MatchingFilter + { + set => Alpha = value ? 1 : 0; + } + + public bool FilteringActive + { + set { } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + voted.BindValueChanged(_ => + { + votedBackground.FadeColour(voted.Value ? colours.Lime2 : colours.Gray2, 250, Easing.OutQuint); + votedIcon.FadeColour(voted.Value ? Colour4.Black : Colour4.White, 250, Easing.OutQuint); + }, true); + FinishTransforms(true); + + updating.BindValueChanged(u => loadingLayer.State.Value = u.NewValue ? Visibility.Visible : Visibility.Hidden); + } + } + } + } +} diff --git a/osu.Game/Screens/Ranking/UserTagControl.DrawableUserTag.cs b/osu.Game/Screens/Ranking/UserTagControl.DrawableUserTag.cs new file mode 100644 index 0000000000..0f88515f59 --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTagControl.DrawableUserTag.cs @@ -0,0 +1,262 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Ranking +{ + public partial class UserTagControl + { + public partial class DrawableUserTag : OsuAnimatedButton + { + /// + /// Minimum count of votes required to display a tag on the beatmap's page. + /// Should match value specified web-side as https://github.com/ppy/osu-web/blob/cae2fdf03cfb8c30c8e332cfb142e03188ceffef/config/osu.php#L59. + /// + public const int MIN_VOTES_DISPLAY = 5; + + public readonly UserTag UserTag; + + public Action? OnSelected { get; set; } + + private readonly Bindable voteCount = new Bindable(); + private readonly BindableBool voted = new BindableBool(); + private readonly Bindable confirmed = new BindableBool(); + private readonly BindableBool updating = new BindableBool(); + + protected Box MainBackground { get; private set; } = null!; + private Box voteBackground = null!; + + protected OsuSpriteText TagCategoryText { get; private set; } = null!; + protected OsuSpriteText TagNameText { get; private set; } = null!; + + private VoteCountText voteCountText = null!; + + private readonly bool showVoteCount; + + private LoadingLayer loadingLayer = null!; + + private FillFlowContainer contentFlow = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public DrawableUserTag(UserTag userTag, bool showVoteCount = true) + { + UserTag = userTag; + this.showVoteCount = showVoteCount; + voteCount.BindTo(userTag.VoteCount); + updating.BindTo(userTag.Updating); + voted.BindTo(userTag.Voted); + + ScaleOnMouseDown = 0.95f; + } + + [BackgroundDependencyLoader] + private void load() + { + CornerRadius = 5; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Colour = colours.Lime1, + Radius = 6, + Type = EdgeEffectType.Glow, + }; + + Content.AddRange(new Drawable[] + { + MainBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + }, + contentFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new[] + { + TagCategoryText = new OsuSpriteText + { + Alpha = UserTag.GroupName != null ? 0.6f : 0, + Text = UserTag.GroupName ?? default(LocalisableString), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Horizontal = 6 } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + Blending = BlendingParameters.Additive, + }, + TagNameText = new OsuSpriteText + { + Text = UserTag.DisplayName, + Font = OsuFont.Default.With(weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Horizontal = 6, Vertical = 3, }, + }, + } + }, + showVoteCount + ? new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + voteBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + voteCountText = new VoteCountText(voteCount) + { + Margin = new MarginPadding { Horizontal = 6 }, + }, + } + } + : Empty(), + } + }, + loadingLayer = new LoadingLayer(dimBackground: true), + }); + + TooltipText = UserTag.Description; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const double transition_duration = 300; + + updating.BindValueChanged(u => loadingLayer.State.Value = u.NewValue ? Visibility.Visible : Visibility.Hidden); + + if (showVoteCount) + { + voteCount.BindValueChanged(_ => + { + confirmed.Value = voteCount.Value >= MIN_VOTES_DISPLAY; + }, true); + voted.BindValueChanged(v => + { + if (v.NewValue) + { + voteBackground.FadeColour(colours.Lime2, transition_duration, Easing.OutQuint); + voteCountText.FadeColour(Colour4.Black, transition_duration, Easing.OutQuint); + } + else + { + voteBackground.FadeColour(colours.Gray2, transition_duration, Easing.OutQuint); + voteCountText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + } + }, true); + + confirmed.BindValueChanged(c => + { + if (c.NewValue) + { + MainBackground.FadeColour(colours.Lime2, transition_duration, Easing.OutQuint); + TagCategoryText.FadeColour(Colour4.Black, transition_duration, Easing.OutQuint); + TagNameText.FadeColour(Colour4.Black, transition_duration, Easing.OutQuint); + FadeEdgeEffectTo(0.3f, transition_duration, Easing.OutQuint); + } + else + { + MainBackground.FadeColour(colours.Gray6, transition_duration, Easing.OutQuint); + TagCategoryText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + TagNameText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + FadeEdgeEffectTo(0f, transition_duration, Easing.OutQuint); + } + }, true); + } + + FinishTransforms(true); + + Action = () => OnSelected?.Invoke(UserTag); + } + + protected override void Update() + { + base.Update(); + + // Grab size from the actual flow. If we were to use AutoSize, the mouse down animation would cause + // our size to change, resulting in weird fill flow interactions. + Size = contentFlow.Size; + } + + private partial class VoteCountText : CompositeDrawable + { + private OsuSpriteText? text; + + private readonly Bindable voteCount; + + public VoteCountText(Bindable voteCount) + { + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; + + this.voteCount = voteCount.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + voteCount.BindValueChanged(count => + { + OsuSpriteText? previousText = text; + + AddInternal(text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold), + Text = voteCount.Value.ToLocalisableString(), + }); + + if (previousText != null) + { + const double transition_duration = 500; + + bool isIncrease = count.NewValue > count.OldValue; + + text.MoveToY(isIncrease ? 20 : -20) + .MoveToY(0, transition_duration, Easing.OutExpo); + + previousText.BypassAutoSizeAxes = Axes.Both; + previousText.MoveToY(isIncrease ? -20 : 20, transition_duration, Easing.OutExpo).Expire(); + + AutoSizeDuration = 300; + AutoSizeEasing = Easing.OutQuint; + } + }, true); + } + } + } + } +} diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs new file mode 100644 index 0000000000..1005e7ea2c --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -0,0 +1,327 @@ +// Copyright (c) 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.Caching; +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; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public partial class UserTagControl : CompositeDrawable + { + private readonly BeatmapInfo beatmapInfo; + + public override bool HandlePositionalInput => true; + + private readonly Cached layout = new Cached(); + + private FillFlowContainer tagFlow = null!; + + private BindableList displayedTags { get; } = new BindableList(); + + private Bindable apiTags = null!; + private BindableDictionary relevantTagsById { get; } = new BindableDictionary(); + + private readonly Bindable apiBeatmap = new Bindable(); + + private AddNewTagUserTag? addNewTagUserTag; + + /// + /// Determines whether the user can modify the contained tags + /// + public bool Writable { private get; init; } + + private InputManager inputManager = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public UserTagControl(BeatmapInfo beatmapInfo) + { + this.beatmapInfo = beatmapInfo; + } + + [BackgroundDependencyLoader] + private void load(SessionStatics sessionStatics) + { + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + ColumnDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + ], + RowDimensions = [new Dimension(GridSizeMode.AutoSize, minSize: 40)], + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(8), + Children = new Drawable[] + { + tagFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(4), + Children = Writable + ? + [ + addNewTagUserTag = new AddNewTagUserTag + { + AvailableTags = { BindTarget = relevantTagsById }, + OnTagSelected = toggleVote, + } + ] + : [] + } + }, + }, + } + } + }, + }; + + apiTags = sessionStatics.GetBindable(Static.AllBeatmapTags); + + if (apiTags.Value == null) + { + var listTagsRequest = new ListTagsRequest(); + listTagsRequest.Success += tags => apiTags.Value = tags.Tags.ToArray(); + api.Queue(listTagsRequest); + } + + var getBeatmapSetRequest = new GetBeatmapSetRequest(beatmapInfo.BeatmapSet!.OnlineID); + getBeatmapSetRequest.Success += set => apiBeatmap.Value = set.Beatmaps.SingleOrDefault(b => b.MatchesOnlineID(beatmapInfo)); + api.Queue(getBeatmapSetRequest); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + apiTags.BindValueChanged(_ => updateTags()); + apiBeatmap.BindValueChanged(_ => updateTags()); + updateTags(); + + displayedTags.BindCollectionChanged(displayTags, true); + + inputManager = GetContainingInputManager()!; + } + + private void updateTags() + { + if (apiTags.Value == null || apiBeatmap.Value == null) + return; + + relevantTagsById.Clear(); + relevantTagsById.AddRange(apiTags.Value + .Where(t => t.RulesetId == null || t.RulesetId == beatmapInfo.Ruleset.OnlineID) + .Select(t => new KeyValuePair(t.Id, new UserTag(t)))); + + foreach (var topTag in apiBeatmap.Value.TopTags ?? []) + { + if (relevantTagsById.TryGetValue(topTag.TagId, out var tag)) + { + tag.VoteCount.Value = topTag.VoteCount; + tag.Updating.Value = false; + displayedTags.Add(tag); + } + } + + foreach (long ownTagId in apiBeatmap.Value.OwnTagIds ?? []) + { + if (relevantTagsById.TryGetValue(ownTagId, out var tag)) + { + tag.Voted.Value = true; + tag.Updating.Value = false; + } + } + } + + private void displayTags(object? sender, NotifyCollectionChangedEventArgs e) + { + var oldItems = tagFlow.ToArray(); + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + for (int i = 0; i < e.NewItems!.Count; i++) + { + var tag = (UserTag)e.NewItems[i]!; + var drawableTag = new DrawableUserTag(tag) { OnSelected = toggleVote }; + tagFlow.Insert(tagFlow.Count, drawableTag); + tag.VoteCount.BindValueChanged(voteCountChanged, true); + layout.Invalidate(); + } + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + for (int i = 0; i < e.OldItems!.Count; i++) + { + var tag = (UserTag)e.OldItems[i]!; + tag.VoteCount.ValueChanged -= voteCountChanged; + tagFlow.Remove(oldItems[1 + e.OldStartingIndex + i], true); + } + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + tagFlow.Clear(); + if (Writable) tagFlow.Add(addNewTagUserTag!); + break; + } + } + } + + private void toggleVote(UserTag tag) + { + if (!Writable) + return; + + if (tag.Updating.Value) + return; + + tag.Updating.Value = true; + + APIRequest request; + + switch (tag.Voted.Value) + { + case true: + var removeReq = new RemoveBeatmapTagRequest(beatmapInfo.OnlineID, tag.Id); + removeReq.Success += () => + { + tag.VoteCount.Value -= 1; + tag.Voted.Value = false; + }; + request = removeReq; + break; + + case false: + var addReq = new AddBeatmapTagRequest(beatmapInfo.OnlineID, tag.Id); + addReq.Success += () => + { + tag.VoteCount.Value += 1; + tag.Voted.Value = true; + if (!displayedTags.Contains(tag)) + displayedTags.Add(tag); + }; + request = addReq; + break; + } + + request.Success += () => tag.Updating.Value = false; + request.Failure += _ => tag.Updating.Value = false; + + api.Queue(request); + } + + private void voteCountChanged(ValueChangedEvent _) + { + var tagsWithNoVotes = displayedTags.Where(t => t.VoteCount.Value == 0).ToArray(); + + foreach (var tag in tagsWithNoVotes) + displayedTags.Remove(tag); + + layout.Invalidate(); + } + + protected override void Update() + { + base.Update(); + + if (!layout.IsValid && !Contains(inputManager.CurrentState.Mouse.Position)) + { + var sortedTags = new Dictionary( + displayedTags.OrderByDescending(t => t.VoteCount.Value) + .ThenByDescending(t => t.Voted.Value) + .Select((tag, index) => new KeyValuePair(tag, index))); + + foreach (var drawableTag in tagFlow) + { + if (drawableTag == addNewTagUserTag) + tagFlow.SetLayoutPosition(drawableTag, float.MinValue); + else + tagFlow.SetLayoutPosition(drawableTag, sortedTags[drawableTag.UserTag]); + } + + layout.Validate(); + } + } + + protected override bool OnClick(ClickEvent e) => true; + + private partial class AddNewTagUserTag : DrawableUserTag, IHasPopover + { + public BindableDictionary AvailableTags { get; } = new BindableDictionary(); + + public Action? OnTagSelected { get; set; } + + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + + public AddNewTagUserTag() + : base(new UserTag(new APITag { Name = "+/add" }), false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AvailableTags.BindCollectionChanged((_, _) => Enabled.Value = AvailableTags.Count > 0, true); + Action = this.ShowPopover; + + MainBackground.FadeColour(overlayColourProvider.Background2); + TagCategoryText.FadeColour(overlayColourProvider.Colour0); + TagNameText.FadeColour(overlayColourProvider.Colour0); + FadeEdgeEffectTo(0); + } + + public Popover GetPopover() => new AddTagsPopover + { + AvailableTags = { BindTarget = AvailableTags }, + OnSelected = OnTagSelected, + }; + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index fc7c7989e2..0d75ddb0f0 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -14,6 +14,7 @@ using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -27,6 +28,7 @@ using osu.Game.Extensions; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Screens.Select.Carousel; +using osu.Game.Screens.Select.Filter; using osuTK; using osuTK.Input; @@ -112,31 +114,17 @@ namespace osu.Game.Screens.Select [Resolved] private RealmAccess realm { get; set; } = null!; - [Resolved] - private DetachedBeatmapStore? detachedBeatmapStore { get; set; } - private IBindableList? detachedBeatmapSets; private readonly NoResultsPlaceholder noResultsPlaceholder; private IEnumerable beatmapSets => root.Items.OfType(); - internal IEnumerable BeatmapSets - { - get => beatmapSets.Select(g => g.BeatmapSet); - set - { - if (LoadState != LoadState.NotLoaded) - throw new InvalidOperationException("If not using a realm source, beatmap sets must be set before load."); - - detachedBeatmapSets = new BindableList(value); - Schedule(loadNewRoot); - } - } + internal IEnumerable BeatmapSets => beatmapSets.Select(g => g.BeatmapSet); private void loadNewRoot() { - beatmapsSplitOut = activeCriteria.SplitOutDifficulties; + beatmapsSplitOut = activeCriteria.Sort == SortMode.Difficulty; // Ensure no changes are made to the list while we are initialising items. // We'll catch up on changes via subscriptions anyway. @@ -198,8 +186,6 @@ namespace osu.Game.Screens.Select private readonly Cached itemsCache = new Cached(); private PendingScrollOperation pendingScrollOperation = PendingScrollOperation.None; - public Bindable RightClickScrollingEnabled = new Bindable(); - public Bindable RandomAlgorithm = new Bindable(); private readonly List previouslyVisitedRandomSets = new List(); private readonly List randomSelectedBeatmaps = new List(); @@ -234,25 +220,16 @@ namespace osu.Game.Screens.Select } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, AudioManager audio, CancellationToken? cancellationToken) + private void load(OsuConfigManager config, AudioManager audio, BeatmapStore beatmaps, CancellationToken? cancellationToken) { spinSample = audio.Samples.Get("SongSelect/random-spin"); randomSelectSample = audio.Samples.Get(@"SongSelect/select-random"); config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); - config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled); - RightClickScrollingEnabled.BindValueChanged(enabled => Scroll.RightMouseScrollbar = enabled.NewValue, true); - - if (detachedBeatmapStore != null && detachedBeatmapSets == null) - { - // This is performing an unnecessary second lookup on realm (in addition to the subscription), but for performance reasons - // we require it to be separate: the subscription's initial callback (with `ChangeSet` of `null`) will run on the update - // thread. If we attempt to detach beatmaps in this callback the game will fall over (it takes time). - detachedBeatmapSets = detachedBeatmapStore.GetDetachedBeatmaps(cancellationToken); - detachedBeatmapSets.BindCollectionChanged(beatmapSetsChanged); - loadNewRoot(); - } + detachedBeatmapSets = beatmaps.GetBeatmapSets(cancellationToken); + detachedBeatmapSets.BindCollectionChanged(beatmapSetsChanged); + loadNewRoot(); } private readonly HashSet setsRequiringUpdate = new HashSet(); @@ -260,26 +237,29 @@ namespace osu.Game.Screens.Select private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) { + IEnumerable? oldBeatmapSets = changed.OldItems?.Cast(); + HashSet oldBeatmapSetIDs = oldBeatmapSets?.Select(s => s.ID).ToHashSet() ?? []; + IEnumerable? newBeatmapSets = changed.NewItems?.Cast(); + HashSet newBeatmapSetIDs = newBeatmapSets?.Select(s => s.ID).ToHashSet() ?? []; switch (changed.Action) { case NotifyCollectionChangedAction.Add: - HashSet newBeatmapSetIDs = newBeatmapSets!.Select(s => s.ID).ToHashSet(); - setsRequiringRemoval.RemoveWhere(s => newBeatmapSetIDs.Contains(s.ID)); setsRequiringUpdate.AddRange(newBeatmapSets!); break; case NotifyCollectionChangedAction.Remove: - IEnumerable oldBeatmapSets = changed.OldItems!.Cast(); - HashSet oldBeatmapSetIDs = oldBeatmapSets.Select(s => s.ID).ToHashSet(); - setsRequiringUpdate.RemoveWhere(s => oldBeatmapSetIDs.Contains(s.ID)); - setsRequiringRemoval.AddRange(oldBeatmapSets); + setsRequiringRemoval.AddRange(oldBeatmapSets!); break; case NotifyCollectionChangedAction.Replace: + setsRequiringUpdate.RemoveWhere(s => oldBeatmapSetIDs.Contains(s.ID)); + setsRequiringRemoval.AddRange(oldBeatmapSets!); + + setsRequiringRemoval.RemoveWhere(s => newBeatmapSetIDs.Contains(s.ID)); setsRequiringUpdate.AddRange(newBeatmapSets!); break; @@ -631,12 +611,12 @@ namespace osu.Game.Screens.Select /// /// The position of the lower visible bound with respect to the current scroll position. /// - private float visibleBottomBound => Scroll.Current + DrawHeight + BleedBottom; + private float visibleBottomBound => (float)(Scroll.Current + DrawHeight + BleedBottom); /// /// The position of the upper visible bound with respect to the current scroll position. /// - private float visibleUpperBound => Scroll.Current - BleedTop; + private float visibleUpperBound => (float)(Scroll.Current - BleedTop); public void FlushPendingFilterOperations() { @@ -680,7 +660,7 @@ namespace osu.Game.Screens.Select { PendingFilter = null; - if (activeCriteria.SplitOutDifficulties != beatmapsSplitOut) + if ((activeCriteria.Sort == SortMode.Difficulty) != beatmapsSplitOut) { loadNewRoot(); return; @@ -724,13 +704,13 @@ namespace osu.Game.Screens.Select switch (e.Action) { case GlobalAction.SelectNext: - case GlobalAction.SelectNextGroup: - SelectNext(1, e.Action == GlobalAction.SelectNextGroup); + case GlobalAction.ActivateNextSet: + SelectNext(1, e.Action == GlobalAction.ActivateNextSet); return true; case GlobalAction.SelectPrevious: - case GlobalAction.SelectPreviousGroup: - SelectNext(-1, e.Action == GlobalAction.SelectPreviousGroup); + case GlobalAction.ActivatePreviousSet: + SelectNext(-1, e.Action == GlobalAction.ActivatePreviousSet); return true; } @@ -1026,7 +1006,7 @@ namespace osu.Game.Screens.Select // we take the difference in scroll height and apply to all visible panels. // this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer // to enter clamp-special-case mode where it animates completely differently to normal. - float scrollChange = scrollTarget.Value - Scroll.Current; + float scrollChange = (float)(scrollTarget.Value - Scroll.Current); Scroll.ScrollTo(scrollTarget.Value, false); foreach (var i in Scroll) i.Y += scrollChange; @@ -1181,10 +1161,8 @@ namespace osu.Game.Screens.Select } } - public partial class CarouselScrollContainer : UserTrackingScrollContainer + public partial class CarouselScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler { - private bool rightMouseScrollBlocked; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; public CarouselScrollContainer() @@ -1196,31 +1174,76 @@ namespace osu.Game.Screens.Select Masking = false; } + #region Absolute scrolling + + private bool absoluteScrolling; + + protected override bool IsDragging => base.IsDragging || absoluteScrolling; + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + beginAbsoluteScrolling(e); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + switch (e.Action) + { + case GlobalAction.AbsoluteScrollSongList: + endAbsoluteScrolling(); + break; + } + } + protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button == MouseButton.Right) { - // we need to block right click absolute scrolling when hovering a carousel item so context menus can display. - // this can be reconsidered when we have an alternative to right click scrolling. - if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) - { - rightMouseScrollBlocked = true; + // To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over. + if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) return false; - } + + beginAbsoluteScrolling(e); } - rightMouseScrollBlocked = false; return base.OnMouseDown(e); } - protected override bool OnDragStart(DragStartEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (rightMouseScrollBlocked) - return false; - - return base.OnDragStart(e); + if (e.Button == MouseButton.Right) + endAbsoluteScrolling(); + base.OnMouseUp(e); } + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (absoluteScrolling) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + return true; + } + + return base.OnMouseMove(e); + } + + private void beginAbsoluteScrolling(UIEvent e) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + absoluteScrolling = true; + } + + private void endAbsoluteScrolling() => absoluteScrolling = false; + + #endregion + protected override ScrollbarContainer CreateScrollbar(Direction direction) { return new PaddedScrollbar(); @@ -1237,12 +1260,12 @@ namespace osu.Game.Screens.Select private const float top_padding = 10; private const float bottom_padding = 70; - protected override float ToScrollbarPosition(float scrollPosition) + protected override float ToScrollbarPosition(double scrollPosition) { if (Precision.AlmostEquals(0, ScrollableExtent)) return 0; - return top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent); + return (float)(top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent)); } protected override float FromScrollbarPosition(float scrollbarPosition) @@ -1250,7 +1273,7 @@ namespace osu.Game.Screens.Select if (Precision.AlmostEquals(0, ScrollbarMovementExtent)) return 0; - return ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding))); + return (float)(ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding)))); } } } diff --git a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs index 8efad451df..e3981c85f0 100644 --- a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs +++ b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs @@ -15,13 +15,13 @@ namespace osu.Game.Screens.Select [Resolved] private ScoreManager scoreManager { get; set; } = null!; - public BeatmapClearScoresDialog(BeatmapInfo beatmapInfo, Action onCompletion) + public BeatmapClearScoresDialog(BeatmapInfo beatmapInfo, Action? onCompletion = null) { BodyText = $"All local scores on {beatmapInfo.GetDisplayTitle()}"; DangerousAction = () => { Task.Run(() => scoreManager.Delete(beatmapInfo)) - .ContinueWith(_ => onCompletion); + .ContinueWith(_ => onCompletion?.Invoke()); }; } } diff --git a/osu.Game/Screens/Select/BeatmapDetailTab.cs b/osu.Game/Screens/Select/BeatmapDetailTab.cs new file mode 100644 index 0000000000..cd219a4830 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailTab.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. + +namespace osu.Game.Screens.Select +{ + public enum BeatmapDetailTab + { + /// + /// Beatmap details. + /// + Details, + + /// + /// Local leaderboards. + /// + Local, + + /// + /// Country leaderboards. + /// + Country, + + /// + /// Global leaderboards. + /// + Global, + + /// + /// Friend leaderboards. + /// + Friends, + + /// + /// Team leaderboards. + /// + Team + } +} diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 2bb60716ff..6a6a4cddf3 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -133,7 +133,7 @@ namespace osu.Game.Screens.Select { description = new MetadataSectionDescription(query => songSelect?.Search(query)), source = new MetadataSectionSource(query => songSelect?.Search(query)), - tags = new MetadataSectionTags(query => songSelect?.Search(query)), + tags = new MetadataSectionMapperTags(query => songSelect?.Search(query)), }, }, }, diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index fd1c944689..79564167f4 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -185,7 +185,7 @@ namespace osu.Game.Screens.Select } private CancellationTokenSource cancellationSource; - private IBindable starDifficulty; + private IBindable starDifficulty; [BackgroundDependencyLoader] private void load(LocalisationManager localisation) @@ -263,7 +263,6 @@ namespace osu.Game.Screens.Select }, new BeatmapSetOnlineStatusPill { - AutoSizeAxes = Axes.Both, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Shear = -wedged_container_shear, @@ -330,7 +329,7 @@ namespace osu.Game.Screens.Select starDifficulty = difficultyCache.GetBindableDifficulty(working.BeatmapInfo, (cancellationSource = new CancellationTokenSource()).Token); starDifficulty.BindValueChanged(s => { - starRatingDisplay.Current.Value = s.NewValue ?? default; + starRatingDisplay.Current.Value = s.NewValue; // Don't roll the counter on initial display (but still allow it to roll on applying mods etc.) if (!starRatingDisplay.IsPresent) diff --git a/osu.Game/Screens/Select/BeatmapInfoWedgeV2.cs b/osu.Game/Screens/Select/BeatmapInfoWedgeV2.cs deleted file mode 100644 index 3c76ae1f08..0000000000 --- a/osu.Game/Screens/Select/BeatmapInfoWedgeV2.cs +++ /dev/null @@ -1,329 +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.Threading; -using osuTK; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; - -namespace osu.Game.Screens.Select -{ - public partial class BeatmapInfoWedgeV2 : VisibilityContainer - { - public const float WEDGE_HEIGHT = 120; - private const float shear_width = 21; - private const float transition_duration = 250; - private const float corner_radius = 10; - private const float colour_bar_width = 30; - - /// Todo: move this const out to song select when more new design elements are implemented for the beatmap details area, since it applies to text alignment of various elements - private const float text_margin = 62; - - private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / WEDGE_HEIGHT, 0); - - [Resolved] - private IBindable ruleset { get; set; } = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } = null!; - - protected Container? DisplayedContent { get; private set; } - - protected WedgeInfoText? Info { get; private set; } - - private Container difficultyColourBar = null!; - private StarCounter starCounter = null!; - private StarRatingDisplay starRatingDisplay = null!; - private BeatmapSetOnlineStatusPill statusPill = null!; - private Container content = null!; - - private IBindable? starDifficulty; - private CancellationTokenSource? cancellationSource; - - public BeatmapInfoWedgeV2() - { - Height = WEDGE_HEIGHT; - Shear = wedged_container_shear; - Masking = true; - Margin = new MarginPadding { Left = -corner_radius }; - EdgeEffect = new EdgeEffectParameters - { - Colour = Colour4.Black.Opacity(0.2f), - Type = EdgeEffectType.Shadow, - Radius = 3, - }; - CornerRadius = corner_radius; - } - - [BackgroundDependencyLoader] - private void load() - { - Child = content = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - // These elements can't be grouped with the rest of the content, due to being present either outside or under the backgrounds area - difficultyColourBar = new Container - { - Colour = Colour4.Transparent, - Depth = float.MaxValue, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - - // By limiting the width we avoid this box showing up as an outline around the drawables that are on top of it. - Width = colour_bar_width + corner_radius, - Child = new Box { RelativeSizeAxes = Axes.Both } - }, - new Container - { - // Applying the shear to this container and nesting the starCounter inside avoids - // the deformation that occurs if the shear is applied to the starCounter whilst rotated - Shear = -wedged_container_shear, - X = -colour_bar_width / 2, - Anchor = Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Width = colour_bar_width, - Child = starCounter = new StarCounter - { - Rotation = (float)(Math.Atan(shear_width / WEDGE_HEIGHT) * (180 / Math.PI)), - Colour = Colour4.Transparent, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(0.35f), - Direction = FillDirection.Vertical - } - }, - new FillFlowContainer - { - Name = "Topright-aligned metadata", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Top = 3, Right = colour_bar_width + 8 }, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(0, 5), - Depth = float.MinValue, - Children = new Drawable[] - { - starRatingDisplay = new StarRatingDisplay(default, animated: true) - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Shear = -wedged_container_shear, - Alpha = 0, - }, - statusPill = new BeatmapSetOnlineStatusPill - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Shear = -wedged_container_shear, - TextSize = 11, - TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, - Alpha = 0, - } - } - }, - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - ruleset.BindValueChanged(_ => updateDisplay()); - - starRatingDisplay.Current.BindValueChanged(s => - { - // use actual stars as star counter has its own animation - starCounter.Current = (float)s.NewValue.Stars; - }, true); - - starRatingDisplay.DisplayedStars.BindValueChanged(s => - { - // sync color with star rating display - starCounter.Colour = s.NewValue >= 6.5 ? colours.Orange1 : Colour4.Black.Opacity(0.75f); - difficultyColourBar.FadeColour(colours.ForStarDifficulty(s.NewValue)); - }, true); - } - - private const double animation_duration = 600; - - protected override void PopIn() - { - this.MoveToX(0, animation_duration, Easing.OutQuint); - this.FadeIn(200, Easing.In); - } - - protected override void PopOut() - { - this.MoveToX(-150, animation_duration, Easing.OutQuint); - this.FadeOut(200, Easing.OutQuint); - } - - private WorkingBeatmap beatmap = null!; - - public WorkingBeatmap Beatmap - { - get => beatmap; - set - { - if (beatmap == value) return; - - beatmap = value; - - updateDisplay(); - } - } - - private Container? loadingInfo; - - private void updateDisplay() - { - statusPill.Status = beatmap.BeatmapInfo.Status; - - starDifficulty = difficultyCache.GetBindableDifficulty(beatmap.BeatmapInfo, (cancellationSource = new CancellationTokenSource()).Token); - - starDifficulty.BindValueChanged(s => - { - starRatingDisplay.Current.Value = s.NewValue ?? default; - - starRatingDisplay.FadeIn(transition_duration); - }); - - Scheduler.AddOnce(() => - { - LoadComponentAsync(loadingInfo = new Container - { - Padding = new MarginPadding { Right = colour_bar_width }, - RelativeSizeAxes = Axes.Both, - Depth = DisplayedContent?.Depth + 1 ?? 0, - Child = new Container - { - Masking = true, - CornerRadius = corner_radius, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - // TODO: New wedge design uses a coloured horizontal gradient for its background, however this lacks implementation information in the figma draft. - // pending https://www.figma.com/file/DXKwqZhD5yyb1igc3mKo1P?node-id=2980:3361#340801912 being answered. - new BeatmapInfoWedgeBackground(beatmap) { Shear = -Shear }, - Info = new WedgeInfoText(beatmap) { Shear = -Shear } - } - } - }, d => - { - // Ensure we are the most recent loaded wedge. - if (d != loadingInfo) return; - - removeOldInfo(); - content.Add(DisplayedContent = d); - }); - }); - - void removeOldInfo() - { - DisplayedContent?.FadeOut(transition_duration); - DisplayedContent?.Expire(); - DisplayedContent = null; - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - cancellationSource?.Cancel(); - } - - public partial class WedgeInfoText : Container - { - public OsuSpriteText TitleLabel { get; private set; } = null!; - public OsuSpriteText ArtistLabel { get; private set; } = null!; - - private readonly WorkingBeatmap working; - - public WedgeInfoText(WorkingBeatmap working) - { - this.working = working; - - RelativeSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(SongSelect? songSelect, LocalisationManager localisation) - { - var metadata = working.Metadata; - - var titleText = new RomanisableString(metadata.TitleUnicode, metadata.Title); - var artistText = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); - - Child = new FillFlowContainer - { - Name = "Top-left aligned metadata", - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Left = text_margin, Top = 12 }, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Children = new Drawable[] - { - new OsuHoverContainer - { - AutoSizeAxes = Axes.Both, - Action = () => songSelect?.Search(titleText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)), - Child = TitleLabel = new TruncatingSpriteText - { - Shadow = true, - Text = titleText, - Font = OsuFont.TorusAlternate.With(size: 40, weight: FontWeight.SemiBold), - }, - }, - new OsuHoverContainer - { - AutoSizeAxes = Axes.Both, - Action = () => songSelect?.Search(artistText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)), - Child = ArtistLabel = new TruncatingSpriteText - { - // TODO : figma design has a diffused shadow, instead of the solid one present here, not possible currently as far as i'm aware. - Shadow = true, - Text = artistText, - // Not sure if this should be semi bold or medium - Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), - }, - }, - } - }; - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - // best effort to confine the auto-sized text to wedge bounds - // the artist label doesn't have an extra text_margin as it doesn't touch the right metadata - TitleLabel.MaxWidth = DrawWidth - text_margin * 2 - shear_width; - ArtistLabel.MaxWidth = DrawWidth - text_margin - shear_width; - } - } - } -} diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index c007fa29ed..39bf4e134b 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Screens.Select.Filter; +using osu.Game.Utils; namespace osu.Game.Screens.Select.Carousel { @@ -59,7 +60,7 @@ namespace osu.Game.Screens.Select.Carousel if (!match) return false; - match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarRating); + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarRating.FloorToDecimalDigits(2)); match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(BeatmapInfo.Difficulty.ApproachRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(BeatmapInfo.Difficulty.DrainRate); match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(BeatmapInfo.Difficulty.CircleSize); @@ -76,12 +77,51 @@ namespace osu.Game.Screens.Select.Carousel if (!match) return false; match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(BeatmapInfo.Metadata.Author.Username); - match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) || - criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); - match &= !criteria.Title.HasFilter || criteria.Title.Matches(BeatmapInfo.Metadata.Title) || - criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); + + if (criteria.Artist.HasFilter) + { + if (criteria.Artist.ExcludeTerm) + match &= criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) && criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); + else + match &= criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) || criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); + } + + if (criteria.Title.HasFilter) + { + if (criteria.Title.ExcludeTerm) + match &= criteria.Title.Matches(BeatmapInfo.Metadata.Title) && criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); + else + match &= criteria.Title.Matches(BeatmapInfo.Metadata.Title) || criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); + } + match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName); match &= !criteria.Source.HasFilter || criteria.Source.Matches(BeatmapInfo.Metadata.Source); + + if (criteria.UserTags.Any()) + { + foreach (var tagFilter in criteria.UserTags) + { + if (tagFilter.ExcludeTerm) + { + // if `ExcludeTerm` is true, `Matches()` will return true if a user tag *doesn't match* the excluded term. + // thus, every user tag must pass this filter. + foreach (string tag in BeatmapInfo.Metadata.UserTags) + match &= tagFilter.Matches(tag); + } + else + { + // if `ExcludeTerm` is false, `Matches()` will return true if a user tag *matches* the expected term. + // the expected behaviour is that a beatmap should be displayed if at least one of the user tags passes the filter. + bool anyTagMatched = false; + + foreach (string tag in BeatmapInfo.Metadata.UserTags) + anyTagMatched |= tagFilter.Matches(tag); + + match &= anyTagMatched; + } + } + } + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); if (!match) return false; @@ -90,6 +130,12 @@ namespace osu.Game.Screens.Select.Carousel if (match && criteria.RulesetCriteria != null) match &= criteria.RulesetCriteria.Matches(BeatmapInfo, criteria); + if (match && criteria.HasOnlineID == true) + match &= BeatmapInfo.OnlineID >= 0; + + if (match && criteria.BeatmapSetId != null) + match &= criteria.BeatmapSetId == BeatmapInfo.BeatmapSet?.OnlineID; + return match; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs index 62d694976f..c0fb5fa397 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs @@ -10,8 +10,31 @@ namespace osu.Game.Screens.Select.Carousel /// /// A group which ensures only one item is selected. /// - public class CarouselGroup : CarouselItem + public abstract class CarouselGroup : CarouselItem { + protected CarouselGroup(List? items = null) + { + if (items != null) this.items = items; + + State.ValueChanged += state => + { + switch (state.NewValue) + { + case CarouselItemState.Collapsed: + case CarouselItemState.NotSelected: + this.items.ForEach(c => c.State.Value = CarouselItemState.Collapsed); + break; + + case CarouselItemState.Selected: + this.items.ForEach(c => + { + if (c.State.Value == CarouselItemState.Collapsed) c.State.Value = CarouselItemState.NotSelected; + }); + break; + } + }; + } + public override DrawableCarouselItem? CreateDrawableRepresentation() => null; public SlimReadOnlyListWrapper Items => items.AsSlimReadOnly(); @@ -67,29 +90,6 @@ namespace osu.Game.Screens.Select.Carousel TotalItemsNotFiltered++; } - public CarouselGroup(List? items = null) - { - if (items != null) this.items = items; - - State.ValueChanged += state => - { - switch (state.NewValue) - { - case CarouselItemState.Collapsed: - case CarouselItemState.NotSelected: - this.items.ForEach(c => c.State.Value = CarouselItemState.Collapsed); - break; - - case CarouselItemState.Selected: - this.items.ForEach(c => - { - if (c.State.Value == CarouselItemState.Collapsed) c.State.Value = CarouselItemState.NotSelected; - }); - break; - } - }; - } - public override void Filter(FilterCriteria criteria) { base.Filter(criteria); diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index cf4ba5924f..8cc1ea258a 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs @@ -10,9 +10,9 @@ namespace osu.Game.Screens.Select.Carousel /// /// A group which ensures at least one item is selected (if the group itself is selected). /// - public class CarouselGroupEagerSelect : CarouselGroup + public abstract class CarouselGroupEagerSelect : CarouselGroup { - public CarouselGroupEagerSelect() + protected CarouselGroupEagerSelect() { State.ValueChanged += state => { diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 75c13c1be6..a8f5b6dd24 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -88,7 +88,10 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private OsuGame? game { get; set; } - private IBindable starDifficultyBindable = null!; + [Resolved] + private BeatmapManager? manager { get; set; } + + private IBindable starDifficultyBindable = null!; private CancellationTokenSource? starDifficultyCancellationSource; public DrawableCarouselBeatmap(CarouselBeatmap panel) @@ -98,7 +101,7 @@ namespace osu.Game.Screens.Select.Carousel } [BackgroundDependencyLoader] - private void load(BeatmapManager? manager, SongSelect? songSelect) + private void load(SongSelect? songSelect) { Header.Height = height; @@ -109,7 +112,7 @@ namespace osu.Game.Screens.Select.Carousel } if (manager != null) - hideRequested = manager.Hide; + hideRequested = b => manager.Hide(b); Header.Children = new Drawable[] { @@ -243,12 +246,11 @@ 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(beatmapInfo, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmapInfo, (starDifficultyCancellationSource = new CancellationTokenSource()).Token, 200); starDifficultyBindable.BindValueChanged(d => { - starCounter.Current = (float)(d.NewValue?.Stars ?? 0); - if (d.NewValue != null) - difficultyIcon.Current.Value = d.NewValue.Value; + starCounter.Current = (float)(d.NewValue.Stars); + difficultyIcon.Current.Value = d.NewValue; }, true); updateKeyCount(); @@ -298,7 +300,10 @@ namespace osu.Game.Screens.Select.Carousel items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); if (beatmapInfo.GetOnlineURL(api, ruleset.Value) is string url) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyUrlToClipboard(url))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url))); + + if (manager != null) + items.Add(new OsuMenuItem("Mark as played", MenuItemType.Standard, () => manager.MarkPlayed(beatmapInfo))); if (hideRequested != null) items.Add(new OsuMenuItem(WebCommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo))); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 996d9ea0ab..f0e024663d 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -301,7 +301,7 @@ namespace osu.Game.Screens.Select.Carousel items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); if (beatmapSet.GetOnlineURL(api, ruleset.Value) is string url) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyUrlToClipboard(url))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url))); if (dialogOverlay != null) items.Add(new OsuMenuItem("Delete...", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet)))); @@ -328,7 +328,7 @@ namespace osu.Game.Screens.Select.Carousel return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s => { - liveCollection.PerformWrite(c => + Task.Run(() => liveCollection.PerformWrite(c => { foreach (var b in beatmapSet.Beatmaps) { @@ -346,7 +346,7 @@ namespace osu.Game.Screens.Select.Carousel break; } } - }); + })); }) { State = { Value = state } diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs index 8d6fbbf256..c3ded16bd2 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -77,7 +77,6 @@ namespace osu.Game.Screens.Select.Carousel }, new BeatmapSetOnlineStatusPill { - AutoSizeAxes = Axes.Both, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, TextSize = 11, diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs index da9661f702..6f1f2e8370 100644 --- a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Models; using osu.Game.Online.API; using osu.Game.Online.Leaderboards; using osu.Game.Rulesets; @@ -59,12 +58,9 @@ namespace osu.Game.Screens.Select.Carousel { scoreSubscription?.Dispose(); scoreSubscription = realm.RegisterForNotifications(r => - r.All() - .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" - + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1" - + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" - + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2" - + $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName), + r.GetAllLocalScoresForUser(api.LocalUser.Value.Id) + .Filter($@"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" + + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1", beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged); }, true); diff --git a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs index e45583887a..d41870f1d2 100644 --- a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs +++ b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs @@ -165,13 +165,13 @@ namespace osu.Game.Screens.Select.Carousel protected override bool OnHover(HoverEvent e) { - icon.Spin(400, RotationDirection.Clockwise); + icon.Spin(400, RotationDirection.Clockwise, icon.Rotation); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - icon.Spin(4000, RotationDirection.Clockwise); + icon.Spin(4000, RotationDirection.Clockwise, icon.Rotation); base.OnHoverLost(e); } } diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index b7086d2416..2d105ae382 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -16,10 +16,12 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Beatmaps; using osu.Framework.Bindables; using System.Collections.Generic; -using osu.Game.Rulesets.Mods; +using System.Diagnostics; using System.Linq; +using osu.Game.Rulesets.Mods; using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Extensions; using osu.Framework.Localisation; using osu.Framework.Threading; @@ -28,21 +30,21 @@ using osu.Game.Configuration; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Difficulty; using osu.Game.Utils; namespace osu.Game.Screens.Select.Details { - public partial class AdvancedStats : Container, IHasCustomTooltip + public partial class AdvancedStats : Container { + private readonly int columns; + [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } - protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; + protected FillFlowContainer Flow { get; private set; } private readonly StatisticRow starDifficulty; - public ITooltip GetCustomTooltip() => new AdjustedAttributesTooltip(); - public AdjustedAttributesTooltip.Data TooltipContent { get; private set; } - private IBeatmapInfo beatmapInfo; public IBeatmapInfo BeatmapInfo @@ -77,65 +79,48 @@ namespace osu.Game.Screens.Select.Details public AdvancedStats(int columns = 1) { + this.columns = columns; + switch (columns) { case 1: - Child = new FillFlowContainer + Child = Flow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new[] { - FirstValue = new StatisticRow(), // circle size/key amount - HpDrain = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsDrain }, - Accuracy = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAccuracy }, - ApproachRate = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAr }, - starDifficulty = new StatisticRow(10, true) { Title = BeatmapsetsStrings.ShowStatsStars }, + starDifficulty = new StatisticRow(forceDecimalPlaces: true) + { + Title = BeatmapsetsStrings.ShowStatsStars, + MaxValue = 10, + }, }, }; break; case 2: - Child = new FillFlowContainer + Child = Flow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, Children = new[] { - FirstValue = new StatisticRow - { - Width = 0.5f, - Padding = new MarginPadding { Right = 5, Vertical = 2.5f }, - }, // circle size/key amount - HpDrain = new StatisticRow - { - Title = BeatmapsetsStrings.ShowStatsDrain, - Width = 0.5f, - Padding = new MarginPadding { Left = 5, Vertical = 2.5f }, - }, - Accuracy = new StatisticRow - { - Title = BeatmapsetsStrings.ShowStatsAccuracy, - Width = 0.5f, - Padding = new MarginPadding { Right = 5, Vertical = 2.5f }, - }, - ApproachRate = new StatisticRow - { - Title = BeatmapsetsStrings.ShowStatsAr, - Width = 0.5f, - Padding = new MarginPadding { Left = 5, Vertical = 2.5f }, - }, - starDifficulty = new StatisticRow(10, true) + starDifficulty = new StatisticRow(forceDecimalPlaces: true) { + MaxValue = 10, Title = BeatmapsetsStrings.ShowStatsStars, Width = 0.5f, - Padding = new MarginPadding { Right = 5, Vertical = 2.5f }, + Padding = new MarginPadding { Horizontal = 5, Vertical = 2.5f }, }, }, }; break; } + + Debug.Assert(Flow != null); + Flow.SetLayoutPosition(starDifficulty, float.MaxValue); } [BackgroundDependencyLoader] @@ -171,54 +156,34 @@ namespace osu.Game.Screens.Select.Details private void updateStatistics() { - IBeatmapDifficultyInfo baseDifficulty = BeatmapInfo?.Difficulty; - BeatmapDifficulty adjustedDifficulty = null; - - if (baseDifficulty != null) + if (BeatmapInfo != null && Ruleset.Value != null) { - BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty); + var displayAttributes = Ruleset.Value.CreateInstance().GetBeatmapAttributesForDisplay(BeatmapInfo, Mods.Value).ToList(); - foreach (var mod in Mods.Value.OfType()) - mod.ApplyToDifficulty(originalDifficulty); - - adjustedDifficulty = originalDifficulty; - - if (Ruleset.Value != null) + // if there are not enough attribute displays, make more + // the subtraction of 1 is to exclude the star rating row which is always present (and always last) + for (int i = Flow.Count - 1; i < displayAttributes.Count; i++) { - double rate = ModUtils.CalculateRateWithMods(Mods.Value); - - adjustedDifficulty = Ruleset.Value.CreateInstance().GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); - - TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, adjustedDifficulty); + Flow.Add(new StatisticRow + { + Width = columns == 1 ? 1 : 0.5f, + Padding = columns == 1 ? new MarginPadding() : new MarginPadding { Horizontal = 5, Vertical = 2.5f }, + }); } + + // populate all attribute displays that need to be visible... + for (int i = 0; i < displayAttributes.Count; i++) + { + var attribute = displayAttributes[i]; + var row = (StatisticRow)Flow.Where(r => r != starDifficulty).ElementAt(i); + row.SetAttribute(attribute); + } + + // and hide any extra ones + foreach (var row in Flow.Where(r => r != starDifficulty).Skip(displayAttributes.Count)) + ((StatisticRow)row).SetAttribute(null); } - switch (Ruleset.Value?.OnlineID) - { - case 3: - // Account for mania differences locally for now. - // Eventually this should be handled in a more modular way, allowing rulesets to return arbitrary difficulty attributes. - ILegacyRuleset legacyRuleset = (ILegacyRuleset)Ruleset.Value.CreateInstance(); - - // For the time being, the key count is static no matter what, because: - // a) The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering. - // b) Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion. - int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, Mods.Value); - - FirstValue.Title = BeatmapsetsStrings.ShowStatsCsMania; - FirstValue.Value = (keyCount, keyCount); - break; - - default: - FirstValue.Title = BeatmapsetsStrings.ShowStatsCs; - FirstValue.Value = (baseDifficulty?.CircleSize ?? 0, adjustedDifficulty?.CircleSize); - break; - } - - HpDrain.Value = (baseDifficulty?.DrainRate ?? 0, adjustedDifficulty?.DrainRate); - Accuracy.Value = (baseDifficulty?.OverallDifficulty ?? 0, adjustedDifficulty?.OverallDifficulty); - ApproachRate.Value = (baseDifficulty?.ApproachRate ?? 0, adjustedDifficulty?.ApproachRate); - updateStarDifficulty(); } @@ -251,7 +216,7 @@ namespace osu.Game.Screens.Select.Details if (normalDifficulty == null || moddedDifficulty == null) return; - starDifficulty.Value = ((float)normalDifficulty.Value.Stars, (float)moddedDifficulty.Value.Stars); + starDifficulty.Value = ((float)normalDifficulty.Value.Stars.FloorToDecimalDigits(2), (float)moddedDifficulty.Value.Stars.FloorToDecimalDigits(2)); }), starDifficultyCancellationSource.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current); }); @@ -262,12 +227,11 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource?.Cancel(); } - public partial class StatisticRow : Container, IHasAccentColour + public partial class StatisticRow : Container, IHasAccentColour, IHasCustomTooltip { private const float value_width = 25; private const float name_width = 70; - private readonly float maxValue; private readonly bool forceDecimalPlaces; private readonly OsuSpriteText name, valueText; private readonly Bar bar; @@ -282,6 +246,8 @@ namespace osu.Game.Screens.Select.Details set => name.Text = value; } + public float MaxValue { get; set; } + private (float baseValue, float? adjustedValue)? value; public (float baseValue, float? adjustedValue) Value @@ -294,10 +260,10 @@ namespace osu.Game.Screens.Select.Details this.value = value; - bar.Length = value.baseValue / maxValue; + bar.Length = value.baseValue / MaxValue; valueText.Text = (value.adjustedValue ?? value.baseValue).ToString(forceDecimalPlaces ? "0.00" : "0.##"); - ModBar.Length = (value.adjustedValue ?? 0) / maxValue; + ModBar.Length = (value.adjustedValue ?? 0) / MaxValue; if (Precision.AlmostEquals(value.baseValue, value.adjustedValue ?? value.baseValue, 0.05f)) ModBar.AccentColour = valueText.Colour = Color4.White; @@ -314,9 +280,8 @@ namespace osu.Game.Screens.Select.Details set => bar.AccentColour = value; } - public StatisticRow(float maxValue = 10, bool forceDecimalPlaces = false) + public StatisticRow(bool forceDecimalPlaces = false) { - this.maxValue = maxValue; this.forceDecimalPlaces = forceDecimalPlaces; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -381,6 +346,26 @@ namespace osu.Game.Screens.Select.Details }, }; } + + public void SetAttribute([CanBeNull] RulesetBeatmapAttribute attribute) + { + if (attribute != null) + { + Title = attribute.Label; + MaxValue = attribute.MaxValue; + Value = (attribute.OriginalValue, attribute.AdjustedValue); + Alpha = 1; + } + else + Alpha = 0; + + TooltipContent = attribute; + } + + public ITooltip GetCustomTooltip() => new BeatmapAttributeTooltip(); + + [CanBeNull] + public RulesetBeatmapAttribute TooltipContent { get; set; } } } } diff --git a/osu.Game/Screens/Select/Filter/GroupMode.cs b/osu.Game/Screens/Select/Filter/GroupMode.cs index d794c215a3..e2bc1faae2 100644 --- a/osu.Game/Screens/Select/Filter/GroupMode.cs +++ b/osu.Game/Screens/Select/Filter/GroupMode.cs @@ -1,55 +1,59 @@ -// 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.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Screens.Select.Filter { public enum GroupMode { - [Description("All")] - All, + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.None))] + None, - [Description("Artist")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Artist))] Artist, - [Description("Author")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Author))] Author, - [Description("BPM")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.BPM))] BPM, - [Description("Collections")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Collections))] Collections, - [Description("Date Added")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.DateAdded))] DateAdded, - [Description("Difficulty")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.DateRanked))] + DateRanked, + + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Difficulty))] Difficulty, - [Description("Favourites")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Favourites))] Favourites, - [Description("Length")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LastPlayed))] + LastPlayed, + + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Length))] Length, - [Description("My Maps")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.MyMaps))] MyMaps, - [Description("No Grouping")] - NoGrouping, - - [Description("Rank Achieved")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.RankAchieved))] RankAchieved, - [Description("Ranked Status")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.RankedStatus))] RankedStatus, - [Description("Recently Played")] - RecentlyPlayed, + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Source))] + Source, - [Description("Title")] - Title + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Title))] + Title, } } diff --git a/osu.Game/Screens/Select/Filter/Operator.cs b/osu.Game/Screens/Select/Filter/Operator.cs index 706daf631f..e9d9af548e 100644 --- a/osu.Game/Screens/Select/Filter/Operator.cs +++ b/osu.Game/Screens/Select/Filter/Operator.cs @@ -11,6 +11,7 @@ namespace osu.Game.Screens.Select.Filter Less, LessOrEqual, Equal, + NotEqual, GreaterOrEqual, Greater } diff --git a/osu.Game/Screens/Select/Filter/SortMode.cs b/osu.Game/Screens/Select/Filter/SortMode.cs index 7f2b33adbe..5dd25d4846 100644 --- a/osu.Game/Screens/Select/Filter/SortMode.cs +++ b/osu.Game/Screens/Select/Filter/SortMode.cs @@ -1,49 +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.ComponentModel; using osu.Framework.Localisation; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Screens.Select.Filter { public enum SortMode { - [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingArtist))] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Artist))] Artist, - [Description("Author")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Author))] Author, - [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.BPM))] BPM, - [Description("Date Submitted")] - DateSubmitted, - - [Description("Date Added")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.DateAdded))] DateAdded, - [Description("Date Ranked")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.DateRanked))] DateRanked, - [Description("Last Played")] - LastPlayed, + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.DateSubmitted))] + DateSubmitted, - [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Difficulty))] Difficulty, - [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksLength))] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LastPlayed))] + LastPlayed, + + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Length))] Length, - // todo: pending support (https://github.com/ppy/osu/issues/4917) - // [Description("Rank Achieved")] + // // todo: pending support (https://github.com/ppy/osu/issues/4917) + // [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.RankAchieved))] // RankAchieved, - [Description("Source")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Source))] Source, - [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingTitle))] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Title))] Title, } } diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index b221296ba8..4781a3dee7 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -57,7 +57,7 @@ namespace osu.Game.Screens.Select [CanBeNull] private FilterCriteria currentCriteria; - public FilterCriteria CreateCriteria() + public virtual FilterCriteria CreateCriteria() { string query = searchTextBox.Text; @@ -90,7 +90,7 @@ namespace osu.Game.Screens.Select private void load(OsuColour colours, OsuConfigManager config) { sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); - groupMode = config.GetBindable(OsuSetting.SongSelectGroupingMode); + groupMode = config.GetBindable(OsuSetting.SongSelectGroupMode); Children = new Drawable[] { diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 76c0f769f0..485c4d1d72 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -20,11 +20,6 @@ namespace osu.Game.Screens.Select public GroupMode Group; public SortMode Sort; - /// - /// Whether the display of beatmap sets should be split apart per-difficulty for the current criteria. - /// - public bool SplitOutDifficulties => Sort == SortMode.Difficulty; - public BeatmapSetInfo? SelectedBeatmapSet; public OptionalRange StarDifficulty; @@ -44,6 +39,7 @@ namespace osu.Game.Screens.Select public OptionalTextFilter Title; public OptionalTextFilter DifficultyName; public OptionalTextFilter Source; + public List UserTags = []; public OptionalRange UserStarDifficulty = new OptionalRange { @@ -56,6 +52,9 @@ namespace osu.Game.Screens.Select public RulesetInfo? Ruleset; public IReadOnlyList? Mods; public bool AllowConvertedBeatmaps; + public int? BeatmapSetId; + + public bool? HasOnlineID; private string searchText = string.Empty; @@ -119,6 +118,18 @@ namespace osu.Game.Screens.Select public IRulesetFilterCriteria? RulesetCriteria { get; set; } + /// + /// The user ID of the current local user, used to filter to own maps when is selected. + /// Or null if the user is not logged in. + /// + public int? LocalUserId { get; set; } + + /// + /// The username of the current local user, used to filter to own maps when is selected. + /// Or null if the user is not logged in. + /// + public string? LocalUserUsername { get; set; } + public readonly struct OptionalSet : IEquatable> where T : struct, Enum { @@ -143,29 +154,25 @@ namespace osu.Game.Screens.Select public bool IsInRange(T value) { + bool lowerRangeSatisfied = true; + bool upperRangeSatisfied = true; + if (Min != null) { int comparison = Comparer.Default.Compare(value, Min.Value); - - if (comparison < 0) - return false; - - if (comparison == 0 && !IsLowerInclusive) - return false; + lowerRangeSatisfied = comparison > 0 || (comparison == 0 && IsLowerInclusive); } if (Max != null) { int comparison = Comparer.Default.Compare(value, Max.Value); - - if (comparison > 0) - return false; - - if (comparison == 0 && !IsUpperInclusive) - return false; + upperRangeSatisfied = comparison < 0 || (comparison == 0 && IsUpperInclusive); } - return true; + bool result = lowerRangeSatisfied && upperRangeSatisfied; + if (InvertRange) + result = !result; + return result; } public T? Min; @@ -173,6 +180,11 @@ namespace osu.Game.Screens.Select public bool IsLowerInclusive; public bool IsUpperInclusive; + /// + /// When , the meaning of this filter is inverted, i.e. it will exclude items that satisfy this range. + /// + public bool InvertRange; + public bool Equals(OptionalRange other) => EqualityComparer.Default.Equals(Min, other.Min) && EqualityComparer.Default.Equals(Max, other.Max) @@ -193,7 +205,9 @@ namespace osu.Game.Screens.Select // search term is guaranteed to be non-empty, so if the string we're comparing is empty, it's not matching if (string.IsNullOrEmpty(value)) - return false; + return ExcludeTerm; + + bool result; switch (MatchMode) { @@ -201,16 +215,29 @@ namespace osu.Game.Screens.Select case MatchMode.Substring: // Note that we are using ordinal here to avoid performance issues caused by globalisation concerns. // See https://github.com/ppy/osu/issues/11571 / https://github.com/dotnet/docs/issues/18423. - return value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); + result = value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); + break; case MatchMode.IsolatedPhrase: - return Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + result = Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + break; case MatchMode.FullPhrase: - return CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0; + result = CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0; + break; } + + if (ExcludeTerm) + result = !result; + + return result; } + /// + /// When , the meaning of this filter is inverted, i.e. it will exclude items which match . + /// + public bool ExcludeTerm; + private string searchTerm; public string SearchTerm diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 78f3bab114..8cf3bda1c5 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Select public static class FilterQueryParser { private static readonly Regex query_syntax_regex = new Regex( - @"\b(?\w+)(?(:|=|(>|<)(:|=)?))(?("".*""[!]?)|(\S*))", + @"\b(?\w+)(?(!?(:|=)|(>|<)(:|=)?))(?("".*?""[!]?)|(\S*))", RegexOptions.Compiled | RegexOptions.IgnoreCase); internal static void ApplyQueries(FilterCriteria criteria, string query) @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Select case "star": case "stars": case "sr": - return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0.01d / 2); + return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0); case "ar": return TryUpdateCriteriaRange(ref criteria.ApproachRate, op, value); @@ -57,7 +57,7 @@ namespace osu.Game.Screens.Select return TryUpdateCriteriaRange(ref criteria.OverallDifficulty, op, value); case "bpm": - return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.01d / 2); + return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.5f); case "length": return tryUpdateLengthRange(criteria, op, value); @@ -76,6 +76,8 @@ namespace osu.Game.Screens.Select return false; // Unplayed beatmaps are filtered on DateTimeOffset.MinValue. + if (op == Operator.NotEqual) + played = !played; if (played) { @@ -116,6 +118,12 @@ namespace osu.Game.Screens.Select case "source": return TryUpdateCriteriaText(ref criteria.Source, op, value); + case "tag": + var tagFilter = new FilterCriteria.OptionalTextFilter(); + TryUpdateCriteriaText(ref tagFilter, op, value); + criteria.UserTags.Add(tagFilter); + return true; + default: return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false; } @@ -129,6 +137,10 @@ namespace osu.Game.Screens.Select case ":": return Operator.Equal; + case "!=": + case "!:": + return Operator.NotEqual; + case "<": return Operator.Less; @@ -222,6 +234,10 @@ namespace osu.Game.Screens.Select { switch (op) { + case Operator.NotEqual: + textFilter.ExcludeTerm = true; + goto case Operator.Equal; + case Operator.Equal: textFilter.SearchTerm = value; return true; @@ -249,14 +265,22 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, float value, float tolerance = 0.05f) { + range.InvertRange = false; + switch (op) { default: return false; + case Operator.NotEqual: + range.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; + if (tolerance == 0) + range.IsLowerInclusive = range.IsUpperInclusive = true; break; case Operator.Greater: @@ -297,14 +321,22 @@ namespace osu.Game.Screens.Select private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, double value, double tolerance = 0.05) { + range.InvertRange = false; + switch (op) { default: return false; + case Operator.NotEqual: + range.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; + if (tolerance == 0) + range.IsLowerInclusive = range.IsUpperInclusive = true; break; case Operator.Greater: @@ -313,6 +345,8 @@ namespace osu.Game.Screens.Select case Operator.GreaterOrEqual: range.Min = value - tolerance; + if (tolerance == 0) + range.IsLowerInclusive = true; break; case Operator.Less: @@ -321,6 +355,8 @@ namespace osu.Game.Screens.Select case Operator.LessOrEqual: range.Max = value + tolerance; + if (tolerance == 0) + range.IsUpperInclusive = true; break; } @@ -364,17 +400,30 @@ namespace osu.Game.Screens.Select { var matchingValues = new HashSet(); - if (op == Operator.Equal && filterValue.Contains(',')) + if (filterValue.Contains(',')) { string[] splitValues = filterValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + HashSet parsedValues = new HashSet(); foreach (string splitValue in splitValues) { if (!tryParseEnum(splitValue, out var parsedValue)) return false; - matchingValues.Add(parsedValue); + parsedValues.Add(parsedValue); } + + if (op == Operator.Equal) + { + matchingValues.UnionWith(parsedValues); + } + else if (op == Operator.NotEqual) + { + matchingValues.UnionWith(Enum.GetValues()); + matchingValues.ExceptWith(parsedValues); + } + else + return false; } else { @@ -409,6 +458,10 @@ namespace osu.Game.Screens.Select if (compareResult > 0) matchingValues.Add(val); break; + case Operator.NotEqual: + if (compareResult != 0) matchingValues.Add(val); + break; + default: return false; } @@ -427,6 +480,10 @@ namespace osu.Game.Screens.Select default: return false; + case Operator.NotEqual: + range.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: range.IsLowerInclusive = range.IsUpperInclusive = true; range.Min = value; @@ -507,13 +564,15 @@ namespace osu.Game.Screens.Select { switch (op) { + case Operator.NotEqual: case Operator.Equal: - // an equality filter is difficult to define for support here. + // an equality or inequality filter is difficult to define for support here. // if "3 months 2 days ago" means a single concrete time instant, such a filter is basically useless. // if it means a range of 24 hours, then that is annoying to write and also comes with its own implications // (does it mean "time instant 3 months 2 days ago, within 12 hours of tolerance either direction"? // does it mean "the full calendar day, from midnight to midnight, 3 months 2 days ago"?) // as such, for simplicity, just refuse to support this. + // same applies to inequality, but instead 24 hours would be need to be left out return false; // for the remaining operators, since the value provided to this function is an "ago" type value @@ -666,6 +725,7 @@ namespace osu.Game.Screens.Select try { DateTimeOffset dateTimeOffset; + dateRange.InvertRange = false; switch (op) { @@ -721,6 +781,11 @@ namespace osu.Game.Screens.Select dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + case Operator.NotEqual: + + dateRange.InvertRange = true; + goto case Operator.Equal; + case Operator.Equal: DateTimeOffset minDateTimeOffset; diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 128e750dca..dafa0b0c1c 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -25,6 +25,11 @@ namespace osu.Game.Screens.Select protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); + /// + /// Used to show an initial animation hinting at the enabled state. + /// + protected virtual bool IsActive => false; + public LocalisableString Text { get => SpriteText?.Text ?? default; @@ -124,6 +129,18 @@ namespace osu.Game.Screens.Select { base.LoadComplete(); Enabled.BindValueChanged(_ => updateDisplay(), true); + + if (IsActive) + { + box.ClearTransforms(); + + using (box.BeginDelayedSequence(200)) + { + box.FadeIn(200) + .Then() + .FadeOut(1500, Easing.OutQuint); + } + } } public Action Hovered; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 58c14b15b9..ddb7814d12 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -3,21 +3,16 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Extensions; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Leaderboards; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using Realms; namespace osu.Game.Screens.Select.Leaderboards { @@ -67,6 +62,8 @@ namespace osu.Game.Screens.Select.Leaderboards } } + private readonly IBindable fetchedScores = new Bindable(); + [Resolved] private IBindable ruleset { get; set; } = null!; @@ -74,17 +71,7 @@ namespace osu.Game.Screens.Select.Leaderboards private IBindable> mods { get; set; } = null!; [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private RulesetStore rulesets { get; set; } = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - private IDisposable? scoreSubscription; - - private GetScoresRequest? scoreRetrievalRequest; + private LeaderboardManager leaderboardManager { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -97,140 +84,55 @@ namespace osu.Game.Screens.Select.Leaderboards }; } + private bool initialFetchComplete; + protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override APIRequest? FetchScores(CancellationToken cancellationToken) { - scoreRetrievalRequest?.Cancel(); - scoreRetrievalRequest = null; - var fetchBeatmapInfo = BeatmapInfo; + var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo?.Ruleset; + // Without this check, an initial fetch will be performed and clear global cache. if (fetchBeatmapInfo == null) - { - SetErrorState(LeaderboardState.NoneSelected); return null; + + // For now, we forcefully refresh to keep things simple. + // In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios + // (like returning from gameplay after setting a new score, returning to song select after main menu). + leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.Where(m => m.UserPlayable).ToArray() : null), forceRefresh: true); + + if (!initialFetchComplete) + { + // only bind this after the first fetch to avoid reading stale scores. + fetchedScores.BindTo(leaderboardManager.Scores); + fetchedScores.BindValueChanged(_ => updateScores(), true); + initialFetchComplete = true; } - var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; - - if (Scope == BeatmapLeaderboardScope.Local) - { - subscribeToLocalScores(fetchBeatmapInfo, cancellationToken); - return null; - } - - if (!api.IsLoggedIn) - { - SetErrorState(LeaderboardState.NotLoggedIn); - return null; - } - - if (!fetchRuleset.IsLegacyRuleset()) - { - SetErrorState(LeaderboardState.RulesetUnavailable); - return null; - } - - if (fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending) - { - SetErrorState(LeaderboardState.BeatmapUnavailable); - return null; - } - - if (!api.LocalUser.Value.IsSupporter && (Scope != BeatmapLeaderboardScope.Global || filterMods)) - { - SetErrorState(LeaderboardState.NotSupporter); - return null; - } - - IReadOnlyList? requestMods = null; - - if (filterMods && !mods.Value.Any()) - // add nomod for the request - requestMods = new Mod[] { new ModNoMod() }; - else if (filterMods) - requestMods = mods.Value; - - var newRequest = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); - newRequest.Success += response => Schedule(() => - { - // Request may have changed since fetch request. - // Can't rely on request cancellation due to Schedule inside SetScores so let's play it safe. - if (!newRequest.Equals(scoreRetrievalRequest)) - return; - - SetScores( - response.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo)).OrderByTotalScore(), - response.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo) - ); - }); - - return scoreRetrievalRequest = newRequest; + return null; } - protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope) + private void updateScores() + { + var scores = fetchedScores.Value; + + if (scores == null) return; + + if (scores.FailState == null) + Schedule(() => SetScores(scores.TopScores, scores.UserScore)); + else + Schedule(() => SetErrorState((LeaderboardState)scores.FailState)); + } + + protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend) { Action = () => ScoreSelected?.Invoke(model) }; - protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false) + protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false, Scope != BeatmapLeaderboardScope.Friend) { Action = () => ScoreSelected?.Invoke(model) }; - - private void subscribeToLocalScores(BeatmapInfo beatmapInfo, CancellationToken cancellationToken) - { - Debug.Assert(beatmapInfo != null); - - scoreSubscription?.Dispose(); - scoreSubscription = null; - - scoreSubscription = realm.RegisterForNotifications(r => - r.All().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" - + $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" - + $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" - + $" AND {nameof(ScoreInfo.DeletePending)} == false" - , beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged); - - void localScoresChanged(IRealmCollection sender, ChangeSet? changes) - { - if (cancellationToken.IsCancellationRequested) - return; - - // This subscription may fire from changes to linked beatmaps, which we don't care about. - // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. - if (changes?.HasCollectionChanges() == false) - return; - - var scores = sender.AsEnumerable(); - - if (filterMods && !mods.Value.Any()) - { - // we need to filter out all scores that have any mods to get all local nomod scores - scores = scores.Where(s => !s.Mods.Any()); - } - else if (filterMods) - { - // otherwise find all the scores that have all of the currently selected mods (similar to how web applies mod filters) - // we're creating and using a string HashSet representation of selected mods so that it can be translated into the DB query itself - var selectedMods = mods.Value.Select(m => m.Acronym).ToHashSet(); - - scores = scores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym))); - } - - scores = scores.Detach().OrderByTotalScore(); - - SetScores(scores); - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - scoreSubscription?.Dispose(); - scoreRetrievalRequest?.Cancel(); - } } } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs index e2e3404877..497e456881 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs @@ -1,24 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; using osu.Framework.Localisation; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Screens.Select.Leaderboards { public enum BeatmapLeaderboardScope { - [Description("Local Ranking")] + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Local))] Local, - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardGlobal))] + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Global))] Global, - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardCountry))] + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Country))] Country, - [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardFriend))] + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Friend))] Friend, + + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Team))] + Team, } } diff --git a/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs b/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs new file mode 100644 index 0000000000..dfe95b8ccd --- /dev/null +++ b/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs @@ -0,0 +1,156 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Online.Rooms; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; +using osu.Game.Screens.Play; +using osu.Game.Users; + +namespace osu.Game.Screens.Select.Leaderboards +{ + /// + /// Represents a score shown on a gameplay leaderboard. + /// The score is expected to update itself as gameplay progresses. + /// + public class GameplayLeaderboardScore + { + /// + /// The user playing. + /// + public IUser User { get; } + + /// + /// Whether the score is being tracked. + /// Generally understood as true when this score is the score of the local user currently playing. + /// + public bool Tracked { get; } + + /// + /// The current total of the score. + /// + public BindableLong TotalScore { get; } = new BindableLong(); + + /// + /// The current accuracy of the score. + /// + public BindableDouble Accuracy { get; } = new BindableDouble(); + + /// + /// The combo of the score to display. + /// Can be either highest combo or current combo, depending on constructor parameters. + /// + public BindableInt Combo { get; } = new BindableInt(); + + /// + /// Whether the user playing has quit. + /// + public BindableBool HasQuit { get; } = new BindableBool(); + + /// + /// An optional value to guarantee stable ordering. + /// Lower numbers will appear higher in cases of ties. + /// + public long TotalScoreTiebreaker { get; init; } + + /// + /// A custom function which handles converting a score to a display score using a provided . + /// + /// + /// If no function is provided, will be used verbatim. + /// + public Func GetDisplayScore { get; set; } + + /// + /// The colour of the team that the user playing is on, if any. + /// + public Colour4? TeamColour { get; init; } + + /// + /// The initial position of the score on the leaderboard. + /// Mostly used for cases like the local user's best score on the global leaderboard (which will not be contiguous with the other scores). + /// + public int? InitialPosition { get; init; } + + /// + /// The displayed rank of the score on the leaderboard. + /// + public Bindable Position { get; } = new Bindable(); + + /// + /// The index of the score on the leaderboard. + /// This differs from in that it is required (must always be known) + /// and that it doesn't represent the score's position on global leaderboards. + /// It's a property completely local to and relative to all scores provided by the managing . + /// + public Bindable DisplayOrder { get; } = new BindableLong(); + + public GameplayLeaderboardScore(GameplayState gameplayState, bool tracked, ComboDisplayMode comboMode) + { + User = gameplayState.Score.ScoreInfo.User; + Tracked = tracked; + + var scoreProcessor = gameplayState.ScoreProcessor; + TotalScore.BindTarget = scoreProcessor.TotalScore; + Accuracy.BindTarget = scoreProcessor.Accuracy; + Combo.BindTarget = comboMode == ComboDisplayMode.Current ? scoreProcessor.Combo : scoreProcessor.HighestCombo; + GetDisplayScore = scoreProcessor.GetDisplayScore; + } + + public GameplayLeaderboardScore(IUser user, SpectatorScoreProcessor scoreProcessor, bool tracked, ComboDisplayMode comboMode) + { + User = user; + Tracked = tracked; + TotalScore.BindTarget = scoreProcessor.TotalScore; + Accuracy.BindTarget = scoreProcessor.Accuracy; + Combo.BindTarget = comboMode == ComboDisplayMode.Current ? scoreProcessor.Combo : scoreProcessor.HighestCombo; + GetDisplayScore = scoreProcessor.GetDisplayScore; + } + + public GameplayLeaderboardScore(ScoreInfo scoreInfo, bool tracked, ComboDisplayMode comboMode) + { + User = scoreInfo.User; + Tracked = tracked; + TotalScore.Value = scoreInfo.TotalScore; + Accuracy.Value = scoreInfo.Accuracy; + Combo.Value = comboMode == ComboDisplayMode.Current ? scoreInfo.Combo : scoreInfo.MaxCombo; + TotalScoreTiebreaker = scoreInfo.OnlineID > 0 ? scoreInfo.OnlineID : scoreInfo.Date.ToUnixTimeSeconds(); + GetDisplayScore = scoreInfo.GetDisplayScore; + InitialPosition = scoreInfo.Position; + } + + public GameplayLeaderboardScore(MultiplayerScore score, bool tracked, ComboDisplayMode comboMode) + { + User = score.User; + Tracked = tracked; + TotalScore.Value = score.TotalScore; + Accuracy.Value = score.Accuracy; + Combo.Value = comboMode == ComboDisplayMode.Highest ? score.MaxCombo : throw new NotSupportedException($"{comboMode} {nameof(comboMode)} is not supported."); + TotalScoreTiebreaker = score.ID; + GetDisplayScore = score.GetDisplayScore; + InitialPosition = score.Position; + } + + /// + /// Used for testing. + /// + internal GameplayLeaderboardScore(IUser user, bool tracked, Bindable displayScore) + { + User = user; + Tracked = tracked; + TotalScore.BindTarget = displayScore; + GetDisplayScore = _ => displayScore.Value; + } + + public enum ComboDisplayMode + { + Current, + Highest, + } + } +} diff --git a/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs new file mode 100644 index 0000000000..9c4875477c --- /dev/null +++ b/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; + +namespace osu.Game.Screens.Select.Leaderboards +{ + /// + /// Provides a leaderboard to show during gameplay. + /// + public interface IGameplayLeaderboardProvider + { + /// + /// List of all scores to display on the leaderboard. + /// + /// + /// Implementors should ensure that this list is only mutated from the update thread. + /// + IBindableList Scores { get; } + } + + public class EmptyGameplayLeaderboardProvider : IGameplayLeaderboardProvider + { + public IBindableList Scores { get; } = new BindableList(); + } +} diff --git a/osu.Game/Screens/Select/Leaderboards/LeaderboardSortMode.cs b/osu.Game/Screens/Select/Leaderboards/LeaderboardSortMode.cs new file mode 100644 index 0000000000..d5fb2f3c54 --- /dev/null +++ b/osu.Game/Screens/Select/Leaderboards/LeaderboardSortMode.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Select.Leaderboards +{ + public enum LeaderboardSortMode + { + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Score))] + Score, + + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Accuracy))] + Accuracy, + + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.MaxCombo))] + MaxCombo, + + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Misses))] + Misses, + + [LocalisableDescription(typeof(BeatmapLeaderboardWedgeStrings), nameof(BeatmapLeaderboardWedgeStrings.Date))] + Date, + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/MultiSpectatorLeaderboardProvider.cs similarity index 76% rename from osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs rename to osu.Game/Screens/Select/Leaderboards/MultiSpectatorLeaderboardProvider.cs index ed92b719fc..19ae12a6ca 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/MultiSpectatorLeaderboardProvider.cs @@ -4,13 +4,12 @@ using System; using osu.Framework.Timing; using osu.Game.Online.Multiplayer; -using osu.Game.Screens.Play.HUD; -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +namespace osu.Game.Screens.Select.Leaderboards { - public partial class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard + public partial class MultiSpectatorLeaderboardProvider : MultiplayerLeaderboardProvider { - public MultiSpectatorLeaderboard(MultiplayerRoomUser[] users) + public MultiSpectatorLeaderboardProvider(MultiplayerRoomUser[] users) : base(users) { } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs similarity index 65% rename from osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs rename to osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs index 922def6174..08af8926df 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs @@ -8,9 +8,11 @@ using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Containers; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; @@ -20,20 +22,28 @@ 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 +namespace osu.Game.Screens.Select.Leaderboards { [LongRunningLoad] - public partial class MultiplayerGameplayLeaderboard : GameplayLeaderboard + public partial class MultiplayerLeaderboardProvider : CompositeComponent, IGameplayLeaderboardProvider { - protected readonly Dictionary UserScores = new Dictionary(); + public IBindableList Scores => scores; + private readonly BindableList scores = new BindableList(); + protected readonly Dictionary UserScores = new Dictionary(); public readonly SortedDictionary TeamScores = new SortedDictionary(); + public bool HasTeams => TeamScores.Count > 0; + + private readonly MultiplayerRoomUser[] users; + + private readonly Bindable scoringMode = new Bindable(); + private readonly IBindableList playingUserIds = new BindableList(); + [Resolved] - private OsuColour colours { get; set; } = null!; + private UserLookupCache userLookupCache { get; set; } = null!; [Resolved] private SpectatorClient spectatorClient { get; set; } = null!; @@ -42,31 +52,21 @@ namespace osu.Game.Screens.Play.HUD private MultiplayerClient multiplayerClient { get; set; } = null!; [Resolved] - private UserLookupCache userLookupCache { get; set; } = null!; + private OsuColour colours { get; set; } = null!; - private Bindable scoringMode = null!; + private readonly Cached sorting = new Cached(); - private readonly MultiplayerRoomUser[] playingUsers; - - private readonly IBindableList playingUserIds = new BindableList(); - - private bool hasTeams => TeamScores.Count > 0; - - /// - /// Construct a new leaderboard. - /// - /// IDs of all users in this match. - public MultiplayerGameplayLeaderboard(MultiplayerRoomUser[] users) + public MultiplayerLeaderboardProvider(MultiplayerRoomUser[] users) { - playingUsers = users; + this.users = users; } [BackgroundDependencyLoader] private void load(OsuConfigManager config, IAPIProvider api, CancellationToken cancellationToken) { - scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); + config.BindWith(OsuSetting.ScoreDisplayMode, scoringMode); - foreach (var user in playingUsers) + foreach (var user in users) { var scoreProcessor = new SpectatorScoreProcessor(user.UserID); scoreProcessor.Mode.BindTo(scoringMode); @@ -80,29 +80,35 @@ namespace osu.Game.Screens.Play.HUD TeamScores.Add(team, new BindableLong()); } - userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray(), cancellationToken) + userLookupCache.GetUsersAsync(users.Select(u => u.UserID).ToArray(), cancellationToken) .ContinueWith(task => { Schedule(() => { - var users = task.GetResultSafely(); + var lookedUpUsers = task.GetResultSafely(); - for (int i = 0; i < users.Length; i++) + for (int i = 0; i < lookedUpUsers.Length; i++) { - var user = users[i] ?? new APIUser + var user = lookedUpUsers[i] ?? new APIUser { - Id = playingUsers[i].UserID, + Id = users[i].UserID, Username = "Unknown user", }; var trackedUser = UserScores[user.Id]; - var leaderboardScore = Add(user, user.Id == api.LocalUser.Value.Id); - leaderboardScore.GetDisplayScore = trackedUser.ScoreProcessor.GetDisplayScore; - leaderboardScore.Accuracy.BindTo(trackedUser.ScoreProcessor.Accuracy); - leaderboardScore.TotalScore.BindTo(trackedUser.ScoreProcessor.TotalScore); - leaderboardScore.Combo.BindTo(trackedUser.ScoreProcessor.Combo); - leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); + var leaderboardScore = new GameplayLeaderboardScore( + user, + trackedUser.ScoreProcessor, + user.Id == api.LocalUser.Value.Id, + GameplayLeaderboardScore.ComboDisplayMode.Current) + { + HasQuit = { BindTarget = trackedUser.UserQuit }, + TeamColour = UserScores[user.OnlineID].Team is int team ? getTeamColour(team) : null, + }; + leaderboardScore.TotalScore.BindValueChanged(_ => sorting.Invalidate()); + leaderboardScore.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true); + scores.Add(leaderboardScore); } }); }, cancellationToken); @@ -113,7 +119,7 @@ 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 (var user in playingUsers) + foreach (var user in users) { spectatorClient.WatchUser(user.UserID); @@ -125,34 +131,8 @@ namespace osu.Game.Screens.Play.HUD // new players are not supported. playingUserIds.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); playingUserIds.BindCollectionChanged(playingUsersChanged); - } - protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) - { - var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked); - - if (user != null) - { - if (UserScores[user.OnlineID].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; - } + Scheduler.AddDelayed(sort, 1000, true); } private void playingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -176,10 +156,10 @@ namespace osu.Game.Screens.Play.HUD private void updateTotals() { - if (!hasTeams) + if (!HasTeams) return; - foreach (var scores in TeamScores.Values) scores.Value = 0; + foreach (var teamTotal in TeamScores.Values) teamTotal.Value = 0; foreach (var u in UserScores.Values) { @@ -191,13 +171,45 @@ namespace osu.Game.Screens.Play.HUD } } + private Color4 getTeamColour(int team) + { + switch (team) + { + case 0: + return colours.TeamColourRed.Lighten(1.2f); + + default: + return colours.TeamColourBlue.Lighten(1.2f); + } + } + + private void sort() + { + if (sorting.IsValid) + return; + + var orderedByScore = scores + .OrderByDescending(i => i.TotalScore.Value) + .ThenBy(i => i.TotalScoreTiebreaker) + .ToList(); + + for (int i = 0; i < orderedByScore.Count; i++) + { + var score = orderedByScore[i]; + score.DisplayOrder.Value = i; + score.Position.Value = i + 1; + } + + sorting.Validate(); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (spectatorClient.IsNotNull()) { - foreach (var user in playingUsers) + foreach (var user in users) spectatorClient.StopWatchingUser(user.UserID); } } diff --git a/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs new file mode 100644 index 0000000000..ea0a2b68dc --- /dev/null +++ b/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs @@ -0,0 +1,134 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.Select.Leaderboards +{ + [LongRunningLoad] + public partial class PlaylistsGameplayLeaderboardProvider : Component, IGameplayLeaderboardProvider + { + public IBindableList Scores => scores; + private readonly BindableList scores = new BindableList(); + + private readonly Room room; + private readonly PlaylistItem playlistItem; + + private readonly Cached sorting = new Cached(); + private bool isPartial; + + public PlaylistsGameplayLeaderboardProvider(Room room, PlaylistItem playlistItem) + { + this.room = room; + this.playlistItem = playlistItem; + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api, GameplayState? gameplayState) + { + var scoresToShow = new List(); + + var scoresRequest = new IndexPlaylistScoresRequest(room.RoomID!.Value, playlistItem.ID); + api.Perform(scoresRequest); + + var response = scoresRequest.Response; + + if (response != null) + { + isPartial = response.Scores.Count < response.TotalScores; + + for (int i = 0; i < response.Scores.Count; i++) + { + var score = response.Scores[i]; + score.Position = i + 1; + scoresToShow.Add(new GameplayLeaderboardScore(score, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest)); + } + + if (response.UserScore != null && response.Scores.All(s => s.ID != response.UserScore.ID)) + scoresToShow.Add(new GameplayLeaderboardScore(response.UserScore, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest)); + } + + if (gameplayState != null) + { + var localScore = new GameplayLeaderboardScore(gameplayState, tracked: true, GameplayLeaderboardScore.ComboDisplayMode.Highest); + localScore.TotalScore.BindValueChanged(_ => sorting.Invalidate()); + scoresToShow.Add(localScore); + } + + // touching the public bindable must happen on the update thread for general thread safety, + // since we may have external subscribers bound already + Schedule(() => + { + scores.AddRange(scoresToShow); + sort(); + Scheduler.AddDelayed(sort, 1000, true); + }); + } + + // logic shared with SoloGameplayLeaderboardProvider + private void sort() + { + if (sorting.IsValid) + return; + + var orderedByScore = scores + .OrderByDescending(i => i.TotalScore.Value) + .ThenBy(i => i.TotalScoreTiebreaker) + .ToList(); + + int delta = 0; + + for (int i = 0; i < orderedByScore.Count; i++) + { + var score = orderedByScore[i]; + + // see `SoloResultsScreen.FetchScores()` for another place that does the same thing with slight deviations + // if this code is changed, that code should probably be changed as well + + score.DisplayOrder.Value = i + 1; + + // if we know we have all scores there can ever be, we can do the simple and obvious thing. + if (!isPartial) + score.Position.Value = i + 1; + else + { + // we have a partial leaderboard, with potential gaps. + // we have initial score positions which were valid at the point of starting play. + // the assumption here is that non-tracked scores here cannot move around, only tracked ones can. + if (score.Tracked) + { + int? previousScorePosition = i > 0 ? orderedByScore[i - 1].InitialPosition : 0; + int? nextScorePosition = i < orderedByScore.Count - 1 ? orderedByScore[i + 1].InitialPosition : null; + + // if the tracked score is perfectly between two scores which have known neighbouring initial positions, + // we can assign it the position of the previous score plus one... + if (previousScorePosition != null && nextScorePosition != null && previousScorePosition + 1 == nextScorePosition) + { + score.Position.Value = previousScorePosition + 1; + // but we also need to ensure all subsequent scores get shifted down one position, too. + delta++; + } + // conversely, if the tracked score is not between neighbouring two scores and the leaderboard is partial, + // we can't really assign a valid position at all. it could be any number between the two neighbours. + else + score.Position.Value = null; + } + // for non-tracked scores, we just need to apply any delta that might have come from the tracked scores + // which might have been encountered and assigned a position earlier. + else + score.Position.Value = score.InitialPosition + delta; + } + } + + sorting.Validate(); + } + } +} diff --git a/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs new file mode 100644 index 0000000000..69e84ccaf8 --- /dev/null +++ b/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs @@ -0,0 +1,122 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.Select.Leaderboards +{ + public partial class SoloGameplayLeaderboardProvider : Component, IGameplayLeaderboardProvider + { + public IBindableList Scores => scores; + private readonly BindableList scores = new BindableList(); + + [Resolved] + private LeaderboardManager? leaderboardManager { get; set; } + + [Resolved] + private GameplayState? gameplayState { get; set; } + + private readonly Cached sorting = new Cached(); + private bool isPartial; + + protected override void LoadComplete() + { + base.LoadComplete(); + + var globalScores = leaderboardManager?.Scores.Value; + + isPartial = globalScores == null || globalScores.IsPartial; + + List newScores = new List(); + + if (globalScores != null) + { + foreach (var topScore in globalScores.AllScores.OrderByTotalScore()) + { + newScores.Add(new GameplayLeaderboardScore(topScore, false, GameplayLeaderboardScore.ComboDisplayMode.Highest)); + } + } + + if (gameplayState != null) + { + var localScore = new GameplayLeaderboardScore(gameplayState, tracked: true, GameplayLeaderboardScore.ComboDisplayMode.Highest) + { + // Local score should always show lower than any existing scores in cases of ties. + TotalScoreTiebreaker = long.MaxValue + }; + localScore.TotalScore.BindValueChanged(_ => sorting.Invalidate()); + newScores.Add(localScore); + } + + scores.AddRange(newScores); + + Scheduler.AddDelayed(sort, 1000, true); + } + + // logic shared with PlaylistsGameplayLeaderboardProvider + private void sort() + { + if (sorting.IsValid) + return; + + var orderedByScore = scores + .OrderByDescending(i => i.TotalScore.Value) + .ThenBy(i => i.TotalScoreTiebreaker) + .ToList(); + + int delta = 0; + + for (int i = 0; i < orderedByScore.Count; i++) + { + var score = orderedByScore[i]; + + // see `SoloResultsScreen.FetchScores()` for another place that does the same thing with slight deviations + // if this code is changed, that code should probably be changed as well + + score.DisplayOrder.Value = i + 1; + + // if we know we have all scores there can ever be, we can do the simple and obvious thing. + if (!isPartial) + score.Position.Value = i + 1; + else + { + // we have a partial leaderboard, with potential gaps. + // we have initial score positions which were valid at the point of starting play. + // the assumption here is that non-tracked scores here cannot move around, only tracked ones can. + if (score.Tracked) + { + int? previousScorePosition = i > 0 ? orderedByScore[i - 1].InitialPosition : 0; + int? nextScorePosition = i < orderedByScore.Count - 1 ? orderedByScore[i + 1].InitialPosition : null; + + // if the tracked score is perfectly between two scores which have known neighbouring initial positions, + // we can assign it the position of the previous score plus one... + if (previousScorePosition != null && nextScorePosition != null && previousScorePosition + 1 == nextScorePosition) + { + score.Position.Value = previousScorePosition + 1; + // but we also need to ensure all subsequent scores get shifted down one position, too. + delta++; + } + // conversely, if the tracked score is not between neighbouring two scores and the leaderboard is partial, + // we can't really assign a valid position at all. it could be any number between the two neighbours. + else + score.Position.Value = null; + } + // for non-tracked scores, we just need to apply any delta that might have come from the tracked scores + // which might have been encountered and assigned a position earlier. + else + score.Position.Value = score.InitialPosition + delta; + } + } + + sorting.Validate(); + } + } +} diff --git a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs index c4cd44705e..998f94849c 100644 --- a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs +++ b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs @@ -8,6 +8,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Input; using osu.Game.Overlays; using osu.Game.Overlays.OSD; using osu.Game.Rulesets.Mods; @@ -21,7 +22,7 @@ namespace osu.Game.Screens.Select private Bindable> selectedMods { get; set; } = null!; [Resolved] - private OsuConfigManager config { get; set; } = null!; + private RealmKeyBindingStore keyBindingStore { get; set; } = null!; [Resolved] private OnScreenDisplay? onScreenDisplay { get; set; } @@ -55,7 +56,7 @@ namespace osu.Game.Screens.Select if (Precision.AlmostEquals(targetSpeed, 1, 0.005)) { selectedMods.Value = selectedMods.Value.Where(m => m is not ModRateAdjust).ToList(); - onScreenDisplay?.Display(new SpeedChangeToast(config, targetSpeed)); + onScreenDisplay?.Display(new SpeedChangeToast(keyBindingStore, targetSpeed)); return true; } @@ -108,7 +109,7 @@ namespace osu.Game.Screens.Select return false; selectedMods.Value = intendedMods; - onScreenDisplay?.Display(new SpeedChangeToast(config, targetMod.SpeedChange.Value)); + onScreenDisplay?.Display(new SpeedChangeToast(keyBindingStore, targetMod.SpeedChange.Value)); return true; } } diff --git a/osu.Game/Screens/Select/NoResultsPlaceholder.cs b/osu.Game/Screens/Select/NoResultsPlaceholder.cs index 9f870503d3..50577d5fea 100644 --- a/osu.Game/Screens/Select/NoResultsPlaceholder.cs +++ b/osu.Game/Screens/Select/NoResultsPlaceholder.cs @@ -137,8 +137,8 @@ namespace osu.Game.Screens.Select // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. if (filter?.Ruleset?.OnlineID != 0 && filter?.AllowConvertedBeatmaps == false) { - textFlow.AddParagraph("- Try"); - textFlow.AddLink(" enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + textFlow.AddParagraph("- Try "); + textFlow.AddLink("enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); textFlow.AddText("automatic conversion!"); } } diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index 045a518525..572b2427b1 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -89,7 +89,7 @@ namespace osu.Game.Screens.Select.Options Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Shear = new Vector2(0.2f, 0f), + Shear = OsuGame.SHEAR, Masking = true, EdgeEffect = new EdgeEffectParameters { diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs index deb1100dfc..ae318de754 100644 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Select } } - private Bindable selectedTab; + private Bindable selectedTab; private Bindable selectedModsFilter; @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Select [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - selectedTab = config.GetBindable(OsuSetting.BeatmapDetailTab); + selectedTab = config.GetBindable(OsuSetting.BeatmapDetailTab); selectedModsFilter = config.GetBindable(OsuSetting.BeatmapDetailModsFilter); selectedTab.BindValueChanged(tab => CurrentTab.Value = getTabItemFromTabType(tab.NewValue), true); @@ -83,53 +83,60 @@ namespace osu.Game.Screens.Select new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global), new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country), new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend), + new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team), }).ToArray(); - private BeatmapDetailAreaTabItem getTabItemFromTabType(TabType type) + private BeatmapDetailAreaTabItem getTabItemFromTabType(BeatmapDetailTab type) { switch (type) { - case TabType.Details: + case BeatmapDetailTab.Details: return new BeatmapDetailAreaDetailTabItem(); - case TabType.Local: + case BeatmapDetailTab.Local: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local); - case TabType.Global: + case BeatmapDetailTab.Global: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global); - case TabType.Country: + case BeatmapDetailTab.Country: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country); - case TabType.Friends: + case BeatmapDetailTab.Friends: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend); + case BeatmapDetailTab.Team: + return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team); + default: throw new ArgumentOutOfRangeException(nameof(type)); } } - private TabType getTabTypeFromTabItem(BeatmapDetailAreaTabItem item) + private BeatmapDetailTab getTabTypeFromTabItem(BeatmapDetailAreaTabItem item) { switch (item) { case BeatmapDetailAreaDetailTabItem: - return TabType.Details; + return BeatmapDetailTab.Details; case BeatmapDetailAreaLeaderboardTabItem leaderboardTab: switch (leaderboardTab.Scope) { case BeatmapLeaderboardScope.Local: - return TabType.Local; + return BeatmapDetailTab.Local; case BeatmapLeaderboardScope.Country: - return TabType.Country; + return BeatmapDetailTab.Country; case BeatmapLeaderboardScope.Global: - return TabType.Global; + return BeatmapDetailTab.Global; case BeatmapLeaderboardScope.Friend: - return TabType.Friends; + return BeatmapDetailTab.Friends; + + case BeatmapLeaderboardScope.Team: + return BeatmapDetailTab.Team; default: throw new ArgumentOutOfRangeException(nameof(item)); @@ -139,14 +146,5 @@ namespace osu.Game.Screens.Select throw new ArgumentOutOfRangeException(nameof(item)); } } - - public enum TabType - { - Details, - Local, - Country, - Global, - Friends - } } } diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs deleted file mode 100644 index 7b1479f392..0000000000 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ /dev/null @@ -1,172 +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.Extensions.LocalisationExtensions; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Screens; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Localisation; -using osu.Game.Overlays; -using osu.Game.Overlays.Notifications; -using osu.Game.Rulesets.Mods; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Screens.Ranking; -using osu.Game.Users; -using osu.Game.Utils; -using osuTK.Input; - -namespace osu.Game.Screens.Select -{ - public partial class PlaySongSelect : SongSelect - { - private OsuScreen? playerLoader; - - [Resolved] - private INotificationOverlay? notifications { get; set; } - - public override bool AllowExternalScreenChange => true; - - public override MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(Func getBeatmap) => new MenuItem[] - { - new OsuMenuItem(ButtonSystemStrings.Play.ToSentence(), MenuItemType.Highlighted, () => FinaliseSelection(getBeatmap())), - new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => Edit(getBeatmap())) - }; - - protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); - - private PlayBeatmapDetailArea playBeatmapDetailArea = null!; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BeatmapOptions.AddButton(ButtonSystemStrings.Edit.ToSentence(), @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit()); - - AddInternal(new SongSelectTouchInputDetector()); - } - - protected void PresentScore(ScoreInfo score) => - FinaliseSelection(score.BeatmapInfo, score.Ruleset, () => this.Push(new SoloResultsScreen(score))); - - protected override BeatmapDetailArea CreateBeatmapDetailArea() - { - playBeatmapDetailArea = new PlayBeatmapDetailArea - { - Leaderboard = - { - ScoreSelected = PresentScore - } - }; - - return playBeatmapDetailArea; - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - switch (e.Key) - { - case Key.Enter: - case Key.KeypadEnter: - // this is a special hard-coded case; we can't rely on OnPressed (of SongSelect) as GlobalActionContainer is - // matching with exact modifier consideration (so Ctrl+Enter would be ignored). - FinaliseSelection(); - return true; - } - - return base.OnKeyDown(e); - } - - private IReadOnlyList? modsAtGameplayStart; - - private ModAutoplay? getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); - - protected override bool OnStart() - { - if (playerLoader != null) return false; - - modsAtGameplayStart = Mods.Value; - - // Ctrl+Enter should start map with autoplay enabled. - if (GetContainingInputManager()?.CurrentState?.Keyboard.ControlPressed == true) - { - var autoInstance = getAutoplayMod(); - - if (autoInstance == null) - { - notifications?.Post(new SimpleNotification - { - Text = NotificationsStrings.NoAutoplayMod - }); - return false; - } - - var mods = Mods.Value.Append(autoInstance).ToArray(); - - if (!ModUtils.CheckCompatibleSet(mods, out var invalid)) - mods = mods.Except(invalid).Append(autoInstance).ToArray(); - - Mods.Value = mods; - } - - SampleConfirm?.Play(); - - this.Push(playerLoader = new PlayerLoader(createPlayer)); - return true; - - Player createPlayer() - { - Player player; - - var replayGeneratingMod = Mods.Value.OfType().FirstOrDefault(); - - if (replayGeneratingMod != null) - { - player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)) - { - LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } - }; - } - else - { - player = new SoloPlayer - { - LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } - }; - } - - return player; - } - } - - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - revertMods(); - } - - public override bool OnExiting(ScreenExitEvent e) - { - if (base.OnExiting(e)) - return true; - - revertMods(); - return false; - } - - private void revertMods() - { - if (playerLoader == null) return; - - Mods.Value = modsAtGameplayStart; - playerLoader = null; - } - } -} diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 9f7a2c02ff..606d53d884 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; @@ -31,6 +32,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Backgrounds; @@ -82,6 +84,11 @@ namespace osu.Game.Screens.Select /// protected Container FooterPanels { get; private set; } = null!; + /// + /// The that opens the mod select dialog. + /// + protected FooterButton ModsFooterButton { get; private set; } = null!; + /// /// Whether entering editor mode should be allowed. /// @@ -169,10 +176,12 @@ namespace osu.Game.Screens.Select AddRangeInternal(new Drawable[] { + new GlobalScrollAdjustsVolume(), new VerticalMaskingContainer { Children = new Drawable[] { + new GlobalScrollAdjustsVolume(), new GridContainer // used for max width implementation { RelativeSizeAxes = Axes.Both, @@ -211,11 +220,11 @@ namespace osu.Game.Screens.Select }, } }, - FilterControl = new FilterControl + FilterControl = CreateFilterControl().With(d => { - RelativeSizeAxes = Axes.X, - Height = FilterControl.HEIGHT, - }, + d.RelativeSizeAxes = Axes.X; + d.Height = FilterControl.HEIGHT; + }), new GridContainer // used for max width implementation { RelativeSizeAxes = Axes.Both, @@ -375,7 +384,7 @@ namespace osu.Game.Screens.Select BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show()); BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => DeleteBeatmap(Beatmap.Value.BeatmapSetInfo)); - BeatmapOptions.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null); + BeatmapOptions.AddButton(@"Mark", @"as played", FontAwesome.Regular.TimesCircle, colours.Purple, () => beatmaps.MarkPlayed(Beatmap.Value.BeatmapInfo)); BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => ClearScores(Beatmap.Value.BeatmapInfo)); } @@ -384,6 +393,8 @@ namespace osu.Game.Screens.Select SampleConfirm = audio.Samples.Get(@"SongSelect/confirm-selection"); } + protected virtual FilterControl CreateFilterControl() => new FilterControl(); + protected override void LoadComplete() { base.LoadComplete(); @@ -405,9 +416,9 @@ namespace osu.Game.Screens.Select /// Creates the buttons to be displayed in the footer. /// /// A set of and an optional which the button opens when pressed. - protected virtual IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[] + protected virtual IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[] { - (new FooterButtonMods { Current = Mods }, ModSelect), + (ModsFooterButton = new FooterButtonMods { Current = Mods }, ModSelect), (new FooterButtonRandom { NextRandom = () => Carousel.SelectNextRandom(), @@ -416,7 +427,10 @@ namespace osu.Game.Screens.Select (beatmapOptionsButton = new FooterButtonOptions(), BeatmapOptions) }; - protected virtual ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay(); + protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay + { + ShowPresets = true, + }; private DependencyContainer dependencies = null!; @@ -511,12 +525,12 @@ namespace osu.Game.Screens.Select private ScheduledDelegate? selectionChangedDebounce; - private void updateCarouselSelection(ValueChangedEvent? e = null) + private void updateCarouselSelection(ValueChangedEvent e = default) { - var beatmap = e?.NewValue ?? Beatmap.Value; + var beatmap = e.NewValue ?? Beatmap.Value; if (beatmap is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; - if (beatmap.BeatmapSetInfo.Protected && e != null) + if (beatmap.BeatmapSetInfo.Protected) { Logger.Log($"Denying working beatmap switch to protected beatmap {beatmap}"); Beatmap.Value = e.OldValue; @@ -1124,6 +1138,10 @@ namespace osu.Game.Screens.Select { private readonly Action? resetCarouselPosition; + private bool mouseContained; + + private InputManager inputManager = null!; + public LeftSideInteractionContainer(Action resetCarouselPosition) { this.resetCarouselPosition = resetCarouselPosition; @@ -1136,16 +1154,31 @@ namespace osu.Game.Screens.Select protected override bool OnMouseDown(MouseDownEvent e) => true; - protected override bool OnHover(HoverEvent e) + protected override void LoadComplete() { - resetCarouselPosition?.Invoke(); - return base.OnHover(e); + inputManager = GetContainingInputManager()!; + base.LoadComplete(); } - } - internal partial class SoloModSelectOverlay : UserModSelectOverlay - { - protected override bool ShowPresets => true; + protected override void Update() + { + base.Update(); + + // We want to trigger an action whenever the cursor is in the left area of song select. + // Other elements in song select handle input, so rather than using `OnHover` let's check the true mouse position. + if (Contains(inputManager.CurrentState.Mouse.Position)) + { + if (!mouseContained) + { + mouseContained = true; + resetCarouselPosition?.Invoke(); + } + } + else + { + mouseContained = false; + } + } } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs new file mode 100644 index 0000000000..ae1c8eb878 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -0,0 +1,1243 @@ +// Copyright (c) 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.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using Realms; + +namespace osu.Game.Screens.SelectV2 +{ + [Cached] + public partial class BeatmapCarousel : Carousel + { + public Action? RequestPresentBeatmap { private get; init; } + + /// + /// From the provided beatmaps, select the most appropriate one for the user's skill. + /// + public required Action> RequestRecommendedSelection { private get; init; } + + /// + /// Selection requested for the provided beatmap. + /// + public required Action RequestSelection { private get; init; } + + public const float SPACING = 3f; + + private IBindableList detachedBeatmaps = null!; + + private readonly LoadingLayer loading; + + private readonly BeatmapCarouselFilterGrouping grouping; + + /// + /// Total number of beatmap difficulties displayed with the filter. + /// + public int MatchedBeatmapsCount => Filters.Last().BeatmapItemsCount; + + protected override float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) + { + // Group panels do not overlap with any other panel but should overlap with themselves. + if ((top.Model is GroupDefinition) ^ (bottom.Model is GroupDefinition)) + return SPACING * 2; + + if (grouping.BeatmapSetsGroupedTogether) + { + // Give some space around the expanded beatmap set, at the top.. + if (bottom.Model is GroupedBeatmapSet && bottom.IsExpanded) + return SPACING * 2; + + // ..and the bottom. + if (top.Model is GroupedBeatmap && bottom.Model is GroupedBeatmapSet) + return SPACING * 2; + + // Beatmap difficulty panels do not overlap with themselves or any other panel. + if (top.Model is GroupedBeatmap || bottom.Model is GroupedBeatmap) + return SPACING; + } + else + { + if (CurrentSelection != null && (top == CurrentSelectionItem || bottom == CurrentSelectionItem)) + return SPACING * 2; + } + + return -SPACING; + } + + public BeatmapCarousel() + { + DebounceDelay = 100; + DistanceOffscreenToPreload = 100; + + // Account for the osu! logo being in the way. + Scroll.ScrollbarPaddingBottom = 70; + + Filters = new ICarouselFilter[] + { + new BeatmapCarouselFilterMatching(() => Criteria!), + new BeatmapCarouselFilterSorting(() => Criteria!), + grouping = new BeatmapCarouselFilterGrouping + { + GetCriteria = () => Criteria!, + GetCollections = GetAllCollections, + GetLocalUserTopRanks = GetBeatmapInfoGuidToTopRankMapping, + GetFavouriteBeatmapSets = GetFavouriteBeatmapSets, + } + }; + + AddInternal(loading = new LoadingLayer()); + } + + [BackgroundDependencyLoader] + private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken) + { + setupPools(); + detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); + loadSamples(audio); + + config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true); + } + + #region Beatmap source hookup + + private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) => Schedule(() => + { + // This callback is scheduled to ensure there's no added overhead during gameplay. + // If this ever becomes an issue, it's important to note that the actual carousel filtering is already + // implemented in a way it will only run when at song select. + // + // The overhead we are avoiding here is that of this method directly – things like Items.IndexOf calls + // that can be slow for very large beatmap libraries. There are definitely ways to optimise this further. + + // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. + // right now we are managing this locally which is a bit of added overhead. + IEnumerable? newItems = changed.NewItems?.Cast(); + IEnumerable? oldItems = changed.OldItems?.Cast(); + + switch (changed.Action) + { + case NotifyCollectionChangedAction.Add: + if (!newItems!.Any()) + return; + + Items.AddRange(newItems!.SelectMany(s => s.Beatmaps)); + break; + + case NotifyCollectionChangedAction.Remove: + bool selectedSetDeleted = false; + + foreach (var set in oldItems!) + { + foreach (var beatmap in set.Beatmaps) + { + Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi)); + selectedSetDeleted |= CheckModelEquality((CurrentSelection as GroupedBeatmap)?.Beatmap, beatmap); + } + } + + // After removing all items in this batch, we want to make an immediate reselection + // based on adjacency to the previous selection if it was deleted. + // + // This needs to be done immediately to avoid song select making a random selection. + // This needs to be done in this class because we need to know final display order. + // This needs to be done with attention to detail of which beatmaps have not been deleted. + if (selectedSetDeleted && CurrentSelectionIndex != null) + { + var items = GetCarouselItems()!; + if (items.Count == 0) + break; + + bool success = false; + + // Try selecting forwards first + for (int i = CurrentSelectionIndex.Value + 1; i < items.Count; i++) + { + if (attemptSelection(items[i])) + { + success = true; + break; + } + } + + if (success) + break; + + // Then try backwards (we might be at the end of available items). + for (int i = Math.Min(items.Count - 1, CurrentSelectionIndex.Value); i >= 0; i--) + { + if (attemptSelection(items[i])) + break; + } + + bool attemptSelection(CarouselItem item) + { + if (CheckValidForSetSelection(item)) + { + if (item.Model is GroupedBeatmap groupedBeatmap) + { + // check the new selection wasn't deleted above + if (!Items.Contains(groupedBeatmap.Beatmap)) + return false; + + RequestSelection(groupedBeatmap); + return true; + } + + if (item.Model is GroupedBeatmapSet groupedSet) + { + if (oldItems.Contains(groupedSet.BeatmapSet)) + return false; + + selectRecommendedDifficultyForBeatmapSet(groupedSet); + return true; + } + } + + return false; + } + } + + break; + + case NotifyCollectionChangedAction.Move: + // We can ignore move operations as we are applying our own sort in all cases. + break; + + case NotifyCollectionChangedAction.Replace: + var oldSetBeatmaps = oldItems!.Single().Beatmaps; + var newSetBeatmaps = newItems!.Single().Beatmaps.ToList(); + + // Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set. + // Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered + // by users editing the beatmap or by difficulty/metadata recomputation). + // + // In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update. + // We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties + // have been processed) if it becomes an issue for animation or performance reasons. + foreach (var beatmap in oldSetBeatmaps) + { + int previousIndex = Items.IndexOf(beatmap); + Debug.Assert(previousIndex >= 0); + + // we're intentionally being lenient with there being two difficulties with equal online ID or difficulty name. + // this can be the case when the user modifies the beatmap using the editor's "external edit" feature. + BeatmapInfo? matchingNewBeatmap = + newSetBeatmaps.FirstOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ?? + newSetBeatmaps.FirstOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); + + // The matching beatmap may have been deleted or invalidated in some way since this event was fired. + // Let's make sure we have the most up-to-date realm state. + if (matchingNewBeatmap?.ID is Guid matchingID) + matchingNewBeatmap = realm.Run(r => r.FindWithRefresh(matchingID)?.Detach()); + + if (matchingNewBeatmap != null) + { + // TODO: should this exist in song select instead of here? + // we need to ensure the global beatmap is also updated alongside changes. + if (CurrentBeatmap != null && beatmap.Equals(CurrentBeatmap)) + // we don't know in which group the matching new beatmap is, but that's fine - we can keep the previous one for now. + // we are about to modify `Items`, which - if required - will trigger a re-filter, + // which will pick a correct group - if one is present - via `HandleFilterCompleted()`. + RequestSelection(new GroupedBeatmap(CurrentGroupedBeatmap?.Group, matchingNewBeatmap)); + + Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]); + newSetBeatmaps.Remove(matchingNewBeatmap); + } + else + { + Items.RemoveAt(previousIndex); + } + } + + // Add any items which weren't found in the previous pass (difficulty names didn't match). + foreach (var beatmap in newSetBeatmaps) + Items.Add(beatmap); + + break; + + case NotifyCollectionChangedAction.Reset: + Items.Clear(); + break; + } + }); + + #endregion + + #region Selection handling + + protected GroupDefinition? ExpandedGroup { get; private set; } + + protected GroupedBeatmapSet? ExpandedBeatmapSet { get; private set; } + + protected override bool ShouldActivateOnKeyboardSelection(CarouselItem item) => + grouping.BeatmapSetsGroupedTogether && item.Model is GroupedBeatmap; + + /// + /// The currently selected . + /// + /// + /// The selection is never reset due to not existing. It can be set to anything. + /// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches. + /// + public GroupedBeatmap? CurrentGroupedBeatmap + { + get => CurrentSelection as GroupedBeatmap; + set => CurrentSelection = value; + } + + /// + /// The currently selected . + /// + /// + /// This is a property mostly dedicated to external consumers who only care about showing some particular copy of a beatmap + /// (there could be multiple panels for one beatmap due to grouping). + /// Through this property, the carousel basically figures out what group to use internally. + /// + public BeatmapInfo? CurrentBeatmap + { + get => CurrentGroupedBeatmap?.Beatmap; + set + { + if (value == null) + { + CurrentGroupedBeatmap = null; + return; + } + + if (CurrentGroupedBeatmap != null && value.Equals(CurrentGroupedBeatmap.Beatmap)) + return; + + // it is not universally guaranteed that the carousel items will be materialised at the time this is set. + // therefore, in cases where it is known that they will not be, default to a null group. + // even if grouping is active, this will be rectified to a correct group on the next invocation of `HandleFilterCompleted()`. + CurrentGroupedBeatmap = IsLoaded && !IsFiltering + ? GetCarouselItems()?.Select(item => item.Model).OfType().FirstOrDefault(gb => gb.Beatmap.Equals(value)) + : new GroupedBeatmap(null, value); + } + } + + /// + /// Tracks whether the user has manually requested to collapse an open group. + /// In this case, refilters should not forcibly expand groups until the user expands a group again themselves. + /// + private bool userCollapsedGroup; + + protected override void HandleItemActivated(CarouselItem item) + { + try + { + switch (item.Model) + { + case GroupDefinition group: + // Special case – collapsing an open group. + if (ExpandedGroup == group) + { + setExpansionStateOfGroup(ExpandedGroup, false); + ExpandedGroup = null; + userCollapsedGroup = true; + return; + } + + setExpandedGroup(group); + + if (userCollapsedGroup) + { + if (grouping.BeatmapSetsGroupedTogether && CurrentGroupedBeatmap != null && CheckModelEquality(group, CurrentGroupedBeatmap.Group)) + setExpandedSet(new GroupedBeatmapSet(CurrentGroupedBeatmap.Group, CurrentGroupedBeatmap.Beatmap.BeatmapSet!)); + userCollapsedGroup = false; + } + + // If the active selection is within this group, it should get keyboard focus immediately. + if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is GroupedBeatmap gb) + RequestSelection(gb); + + return; + + case GroupedBeatmapSet groupedSet: + selectRecommendedDifficultyForBeatmapSet(groupedSet); + return; + + case GroupedBeatmap groupedBeatmap: + if (CurrentSelection != null && CheckModelEquality(CurrentSelection, groupedBeatmap)) + { + RequestPresentBeatmap?.Invoke(groupedBeatmap.Beatmap); + return; + } + + RequestSelection(groupedBeatmap); + return; + } + } + finally + { + playActivationSound(item); + } + } + + protected override void HandleItemSelected(object? model) + { + base.HandleItemSelected(model); + + switch (model) + { + case GroupedBeatmapSet: + case GroupDefinition: + throw new InvalidOperationException("Groups should never become selected"); + + case GroupedBeatmap groupedBeatmap: + if (userCollapsedGroup) + break; + + setExpandedGroup(groupedBeatmap.Group); + + if (grouping.BeatmapSetsGroupedTogether) + setExpandedSet(new GroupedBeatmapSet(groupedBeatmap.Group, groupedBeatmap.Beatmap.BeatmapSet!)); + break; + } + } + + protected override bool HandleItemsChanged(NotifyCollectionChangedEventArgs args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + case NotifyCollectionChangedAction.Remove: + case NotifyCollectionChangedAction.Move: + case NotifyCollectionChangedAction.Reset: + return true; + + case NotifyCollectionChangedAction.Replace: + var oldBeatmaps = args.OldItems!.OfType().ToList(); + var newBeatmaps = args.NewItems!.OfType().ToList(); + + for (int i = 0; i < oldBeatmaps.Count; i++) + { + var oldBeatmap = oldBeatmaps[i]; + var newBeatmap = newBeatmaps[i]; + + // Ignore changes which don't concern us. + // + // Here are some examples of things that can go wrong: + // - Background difficulty calculation runs and causes a realm update. + // We use `BeatmapDifficultyCache` and don't want to know about these. + // - Background user tag population runs and causes a realm update. + // We don't display user tags so want to ignore this. + bool equalForDisplayPurposes = + // covers import-as-update flows, such as updating the beatmap with the latest online versions, or external editing inside editor + oldBeatmap.ID == newBeatmap.ID && + // covers metadata changes + oldBeatmap.Hash == newBeatmap.Hash && + // sanity check + oldBeatmap.OnlineID == newBeatmap.OnlineID && + // displayed on panel + oldBeatmap.Status == newBeatmap.Status && + // displayed on panel + oldBeatmap.DifficultyName == newBeatmap.DifficultyName && + // hidden changed, needs re-filter + oldBeatmap.Hidden == newBeatmap.Hidden && + // might be used for grouping, returning from gameplay + oldBeatmap.LastPlayed == newBeatmap.LastPlayed; + + if (equalForDisplayPurposes) + return false; + } + + return true; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + protected override void FindCarouselItemsForSelection(ref Selection keyboardSelection, ref Selection selection, IList items) + { + if (keyboardSelection.Model != null && grouping.ItemMap.TryGetValue(keyboardSelection.Model, out var keyboardSelectionItem)) + keyboardSelection = keyboardSelection with { CarouselItem = keyboardSelectionItem.item, Index = keyboardSelectionItem.index }; + + if (selection.Model != null && grouping.ItemMap.TryGetValue(selection.Model, out var selectionItem)) + selection = selection with { CarouselItem = selectionItem.item, Index = selectionItem.index }; + } + + protected override void HandleFilterCompleted() + { + base.HandleFilterCompleted(); + + attemptSelectSingleFilteredResult(); + + // Store selected group before handling selection (it may implicitly change the expanded group). + var groupForReselection = ExpandedGroup; + + var currentGroupedBeatmap = CurrentSelection as GroupedBeatmap; + + // The filter might have changed the set of available groups, which means that the current selection may point to a stale group. + // Check whether that is the case. + bool groupingRemainsOff = currentGroupedBeatmap?.Group == null && grouping.GroupItems.Count == 0; + bool groupStillValid = currentGroupedBeatmap?.Group != null && grouping.ItemMap.ContainsKey(currentGroupedBeatmap); + + if (groupingRemainsOff || groupStillValid) + { + // Only update the visual state of the selected item. + HandleItemSelected(currentGroupedBeatmap); + } + else if (currentGroupedBeatmap != null) + { + // If the group no longer exists (or the item no longer exists in the previous group), grab an arbitrary other instance of the beatmap under the first group encountered. + var newSelection = GetCarouselItems()?.Select(i => i.Model).OfType().FirstOrDefault(gb => gb.Beatmap.Equals(currentGroupedBeatmap.Beatmap)); + + // Only change the selection if we actually got a positive hit. + // This is necessary so that selection isn't lost if the panel reappears later due to e.g. unapplying some filter criteria that made it disappear in the first place. + if (newSelection != null) + { + CurrentSelection = newSelection; + groupForReselection = newSelection.Group; + } + } + + // If a group was selected that is not the one containing the selection, attempt to reselect it. + if (groupForReselection != null && grouping.GroupItems.TryGetValue(groupForReselection, out _)) + setExpandedGroup(groupForReselection); + } + + private void selectRecommendedDifficultyForBeatmapSet(GroupedBeatmapSet set) + { + // Selecting a set isn't valid – let's re-select the first visible difficulty. + if (grouping.SetItems.TryGetValue(set, out var items)) + { + var beatmaps = items.Select(i => i.Model).OfType(); + RequestRecommendedSelection(beatmaps); + } + } + + /// + /// If we don't have a selection and there's a single beatmap set returned, select it for the user. + /// + private void attemptSelectSingleFilteredResult() + { + var items = GetCarouselItems(); + + if (items == null || items.Count == 0) return; + + BeatmapSetInfo? beatmapSetInfo = null; + + foreach (var item in items) + { + if (item.Model is GroupedBeatmap groupedBeatmap) + { + var beatmapInfo = groupedBeatmap.Beatmap; + + if (beatmapSetInfo == null) + { + beatmapSetInfo = beatmapInfo.BeatmapSet!; + continue; + } + + // Found a beatmap with a different beatmap set, abort. + if (!beatmapSetInfo.Equals(beatmapInfo.BeatmapSet)) + return; + } + } + + var beatmaps = items.Select(i => i.Model).OfType(); + + // do not request recommended selection if the user already had selected a difficulty within the single filtered beatmap set, + // as it could change the difficulty that will be selected + var preexistingSelection = beatmaps.FirstOrDefault(b => b.Equals(CurrentSelection as GroupedBeatmap)); + + if (preexistingSelection != null) + { + // the selection might not have an item associated with it, if it was fully filtered away previously + // in this case, request to reselect it + if (CurrentSelectionItem == null) + RequestSelection(preexistingSelection); + + return; + } + + RequestRecommendedSelection(beatmaps); + } + + protected override bool CheckValidForGroupSelection(CarouselItem item) => item.Model is GroupDefinition; + + protected override bool CheckValidForSetSelection(CarouselItem item) + { + switch (item.Model) + { + case GroupedBeatmapSet: + return true; + + case GroupedBeatmap: + return !grouping.BeatmapSetsGroupedTogether; + + case GroupDefinition: + return false; + + default: + throw new ArgumentException($"Unsupported model type {item.Model}"); + } + } + + private void setExpandedGroup(GroupDefinition? group) + { + if (ExpandedGroup != null) + setExpansionStateOfGroup(ExpandedGroup, false); + + ExpandedGroup = group; + + if (ExpandedGroup != null) + setExpansionStateOfGroup(ExpandedGroup, true); + } + + private void setExpansionStateOfGroup(GroupDefinition group, bool expanded) + { + if (grouping.GroupItems.TryGetValue(group, out var items)) + { + if (expanded) + { + foreach (var i in items) + { + switch (i.Model) + { + case GroupDefinition: + i.IsExpanded = true; + break; + + case GroupedBeatmapSet groupedSet: + // Case where there are set headers, header should be visible + // and items should use the set's expanded state. + i.IsVisible = true; + setExpansionStateOfSetItems(groupedSet, i.IsExpanded); + break; + + default: + // Case where there are no set headers, all items should be visible. + if (!grouping.BeatmapSetsGroupedTogether) + i.IsVisible = true; + break; + } + } + } + else + { + foreach (var i in items) + { + switch (i.Model) + { + case GroupDefinition: + i.IsExpanded = false; + break; + + default: + i.IsVisible = false; + break; + } + } + } + } + } + + private void setExpandedSet(GroupedBeatmapSet set) + { + if (ExpandedBeatmapSet != null) + setExpansionStateOfSetItems(ExpandedBeatmapSet, false); + ExpandedBeatmapSet = set; + setExpansionStateOfSetItems(ExpandedBeatmapSet, true); + } + + private void setExpansionStateOfSetItems(GroupedBeatmapSet set, bool expanded) + { + if (grouping.SetItems.TryGetValue(set, out var items)) + { + foreach (var i in items) + { + if (i.Model is GroupedBeatmapSet) + i.IsExpanded = expanded; + else + i.IsVisible = expanded; + } + } + } + + public void ExpandGroupForCurrentSelection() + { + if (CurrentGroupedBeatmap?.Group == null) + return; + + if (CheckModelEquality(ExpandedGroup, CurrentGroupedBeatmap.Group)) + return; + + if (grouping.ItemMap.TryGetValue(CurrentGroupedBeatmap.Group, out var groupItem)) + Activate(groupItem.item); + } + + protected override double? GetScrollTarget() + { + double? target = base.GetScrollTarget(); + + // if the base implementation returned null, it means that the keyboard selection has been filtered out and is no longer visible + // attempt a fallback to other possibly expanded panels (set first, then group) + if (target == null) + { + CarouselItem? targetItem = null; + + if (ExpandedBeatmapSet != null && grouping.ItemMap.TryGetValue(ExpandedBeatmapSet, out var setItem)) + targetItem = setItem.item; + + if (targetItem == null && ExpandedGroup != null && grouping.ItemMap.TryGetValue(ExpandedGroup, out var groupItem)) + targetItem = groupItem.item; + + target = targetItem?.CarouselYPosition; + } + + return target; + } + + #endregion + + #region Audio + + private Sample? sampleChangeDifficulty; + private Sample? sampleChangeSet; + private Sample? sampleToggleGroup; + + private double audioFeedbackLastPlaybackTime; + + private void loadSamples(AudioManager audio) + { + sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty"); + sampleChangeSet = audio.Samples.Get(@"SongSelect/select-expand"); + sampleToggleGroup = audio.Samples.Get(@"SongSelect/select-group"); + + spinSample = audio.Samples.Get("SongSelect/random-spin"); + randomSelectSample = audio.Samples.Get(@"SongSelect/select-random"); + } + + private void playActivationSound(CarouselItem item) + { + if (Time.Current - audioFeedbackLastPlaybackTime >= OsuGameBase.SAMPLE_DEBOUNCE_TIME) + { + switch (item.Model) + { + case GroupDefinition: + sampleToggleGroup?.Play(); + return; + + case GroupedBeatmapSet: + sampleChangeSet?.Play(); + return; + + case GroupedBeatmap: + sampleChangeDifficulty?.Play(); + return; + } + + audioFeedbackLastPlaybackTime = Time.Current; + } + } + + #endregion + + #region Animation + + /// + /// Moves non-selected beatmaps to the right, hiding off-screen. + /// + public bool VisuallyFocusSelected { get; set; } + + private float selectionFocusOffset; + + protected override void Update() + { + base.Update(); + + selectionFocusOffset = (float)Interpolation.DampContinuously(selectionFocusOffset, VisuallyFocusSelected ? 300 : 0, 100, Time.Elapsed); + } + + protected override float GetPanelXOffset(Drawable panel) + { + return base.GetPanelXOffset(panel) + (((ICarouselPanel)panel).Selected.Value ? 0 : selectionFocusOffset); + } + + #endregion + + #region Filtering + + public FilterCriteria? Criteria { get; private set; } + + private ScheduledDelegate? loadingDebounce; + + public void Filter(FilterCriteria criteria, bool showLoadingImmediately = false) + { + bool resetDisplay = grouping.BeatmapSetsGroupedTogether != BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria); + + Criteria = criteria; + + if (criteria.Group == GroupMode.None) + userCollapsedGroup = false; + + loadingDebounce ??= Scheduler.AddDelayed(() => + { + if (loading.State.Value == Visibility.Visible) + return; + + Scroll.FadeColour(OsuColour.Gray(0.5f), 1000, Easing.OutQuint); + loading.Show(); + }, showLoadingImmediately ? 0 : 250); + + FilterAsync(resetDisplay).ContinueWith(_ => Schedule(() => + { + loadingDebounce?.Cancel(); + loadingDebounce = null; + + Scroll.FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint); + loading.Hide(); + })); + } + + protected override Task> FilterAsync(bool clearExistingPanels = false) + { + if (Criteria == null) + return Task.FromResult(Enumerable.Empty()); + + return base.FilterAsync(clearExistingPanels); + } + + #endregion + + #region Fetches for grouping support + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + /// + /// FOOTGUN WARNING: this being sorted on the realm side before detaching is IMPORTANT. + /// realm supports sorting as an internal operation, and realm's implementation of string sorting does NOT match dotnet's + /// with respect to treatment of punctuation characters like - or _, among others. + /// All other places that show lists of collections also use the realm-side sorting implementation, + /// because they use the sorting operation inside subscription queries for efficient drawable management, + /// so this usage kind of has to follow suit. + /// + protected virtual List GetAllCollections() => realm.Run(r => r.All().OrderBy(c => c.Name).AsEnumerable().Detach()); + + protected virtual Dictionary GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => realm.Run(r => + { + var topRankMapping = new Dictionary(); + + var allLocalScores = r.GetAllLocalScoresForUser(criteria.LocalUserId) + .Filter($@"{nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0", criteria.Ruleset?.ShortName) + .OrderByDescending(s => s.TotalScore) + .ThenBy(s => s.Date); + + foreach (var score in allLocalScores) + { + Debug.Assert(score.BeatmapInfo != null); + + if (topRankMapping.ContainsKey(score.BeatmapInfo.ID)) + continue; + + topRankMapping[score.BeatmapInfo.ID] = score.Rank; + } + + return topRankMapping; + }); + + /// + /// Note that calling .ToHashSet() below has two purposes: + /// one being performance of contain checks in filtering code, + /// another being slightly better thread safety (as could be mutated during async filtering). + /// + protected HashSet GetFavouriteBeatmapSets() => api.LocalUserState.FavouriteBeatmapSets.ToHashSet(); + + #endregion + + #region Drawable pooling + + private readonly DrawablePool beatmapPanelPool = new DrawablePool(100); + private readonly DrawablePool standalonePanelPool = new DrawablePool(100); + private readonly DrawablePool setPanelPool = new DrawablePool(100); + private readonly DrawablePool groupPanelPool = new DrawablePool(100); + private readonly DrawablePool starsGroupPanelPool = new DrawablePool(11); + private readonly DrawablePool ranksGroupPanelPool = new DrawablePool(9); + private readonly DrawablePool statusGroupPanelPool = new DrawablePool(8); + + private void setupPools() + { + AddInternal(statusGroupPanelPool); + AddInternal(ranksGroupPanelPool); + AddInternal(starsGroupPanelPool); + AddInternal(groupPanelPool); + AddInternal(beatmapPanelPool); + AddInternal(standalonePanelPool); + AddInternal(setPanelPool); + } + + protected override bool CheckModelEquality(object? x, object? y) + { + // In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale + // BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs. + // + // If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring + // it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged + // before changing matching requirements here. + + if (x is GroupedBeatmapSet groupedSetX && y is GroupedBeatmapSet groupedSetY) + return groupedSetX.Equals(groupedSetY); + + if (x is GroupedBeatmap groupedBeatmapX && y is GroupedBeatmap groupedBeatmapY) + return groupedBeatmapX.Equals(groupedBeatmapY); + + // `BeatmapInfo` is no longer used directly in carousel items, but in rare circumstances still is used for model equality comparisons + // (see `beatmapSetsChanged()` deletion handling logic, which aims to find a beatmap close to the just-deleted one, disregarding grouping concerns) + if (x is BeatmapInfo beatmapInfoX && y is BeatmapInfo beatmapInfoY) + return beatmapInfoX.Equals(beatmapInfoY); + + if (x is StarDifficultyGroupDefinition starX && y is StarDifficultyGroupDefinition starY) + return starX.Equals(starY); + + if (x is RankDisplayGroupDefinition rankX && y is RankDisplayGroupDefinition rankY) + return rankX.Equals(rankY); + + if (x is RankedStatusGroupDefinition statusX && y is RankedStatusGroupDefinition statusY) + return statusX.Equals(statusY); + + // NOTE: this branch must be AFTER all branches that compare `GroupDefinition` subtypes! + // this is an optimisation measure. any subclass of `GroupDefinition` will pass the `is GroupDefinition` check, + // and testing a subclass of `GroupDefinition` against any other `GroupDefinition` (or subclass thereof) + // will result in a casting cascade of `Equals(GroupDefinition) -> Equals(object) -> Equals(GroupDefinitionSubClass)` + // (that last one only if the type check passes) + if (x is GroupDefinition groupX && y is GroupDefinition groupY) + return groupX.Equals(groupY); + + return base.CheckModelEquality(x, y); + } + + protected override Drawable GetDrawableForDisplay(CarouselItem item) + { + switch (item.Model) + { + case RankedStatusGroupDefinition: + return statusGroupPanelPool.Get(); + + case StarDifficultyGroupDefinition: + return starsGroupPanelPool.Get(); + + case RankDisplayGroupDefinition: + return ranksGroupPanelPool.Get(); + + case GroupDefinition: + return groupPanelPool.Get(); + + case GroupedBeatmap: + if (!grouping.BeatmapSetsGroupedTogether) + return standalonePanelPool.Get(); + + return beatmapPanelPool.Get(); + + case GroupedBeatmapSet: + return setPanelPool.Get(); + } + + throw new InvalidOperationException(); + } + + #endregion + + #region Random selection handling + + private readonly Bindable randomAlgorithm = new Bindable(); + private readonly HashSet previouslyVisitedRandomBeatmaps = new HashSet(); + private readonly List randomHistory = new List(); + + private Sample? spinSample; + private Sample? randomSelectSample; + + public bool NextRandom() + { + var carouselItems = GetCarouselItems(); + + if (carouselItems?.Any() != true) + return false; + + var selectionBefore = CurrentSelectionItem; + var beatmapBefore = selectionBefore?.Model as GroupedBeatmap; + + bool success; + + if (beatmapBefore != null) + { + // keep track of visited beatmaps and sets for rewind + randomHistory.Add(beatmapBefore); + // keep track of visited beatmaps for "RandomPermutation" random tracking. + // note that this is reset when we run out of beatmaps, while `randomHistory` is not. + previouslyVisitedRandomBeatmaps.Add(beatmapBefore.Beatmap); + } + + if (grouping.BeatmapSetsGroupedTogether) + success = nextRandomSet(); + else + success = nextRandomBeatmap(); + + if (!success) + { + if (beatmapBefore != null) + randomHistory.RemoveAt(randomHistory.Count - 1); + return false; + } + + // CurrentSelectionItem won't be valid until UpdateAfterChildren. + // We probably want to fix this at some point since a few places are working-around this quirk. + ScheduleAfterChildren(() => + { + if (selectionBefore != null && CurrentSelectionItem != null) + playSpinSample(visiblePanelCountBetweenItems(selectionBefore, CurrentSelectionItem)); + }); + + return true; + } + + private bool nextRandomBeatmap() + { + ICollection visibleBeatmaps = ExpandedGroup != null && grouping.GroupItems.TryGetValue(ExpandedGroup, out var groupItems) + // In the case of grouping, users expect random to only operate on the expanded group. + // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. + // + // If this becomes an issue, we could either store a mapping, or run the random algorithm many times + // using the `SetItems` method until we get a group HIT. + ? groupItems.Select(i => i.Model).OfType().ToArray() + : GetCarouselItems()!.Select(i => i.Model).OfType().ToArray(); + + GroupedBeatmap beatmap; + + switch (randomAlgorithm.Value) + { + case RandomSelectAlgorithm.RandomPermutation: + { + ICollection notYetVisitedBeatmaps = visibleBeatmaps.ExceptBy(previouslyVisitedRandomBeatmaps, gb => gb.Beatmap).ToList(); + + if (!notYetVisitedBeatmaps.Any()) + { + previouslyVisitedRandomBeatmaps.ExceptWith(visibleBeatmaps.Select(b => b.Beatmap)); + notYetVisitedBeatmaps = visibleBeatmaps; + if (CurrentSelection is GroupedBeatmap groupedBeatmap) + notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([groupedBeatmap]).ToList(); + } + + if (notYetVisitedBeatmaps.Count == 0) + return false; + + beatmap = notYetVisitedBeatmaps.ElementAt(RNG.Next(notYetVisitedBeatmaps.Count)); + break; + } + + case RandomSelectAlgorithm.Random: + beatmap = visibleBeatmaps.ElementAt(RNG.Next(visibleBeatmaps.Count)); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + RequestSelection(beatmap); + return true; + } + + private bool nextRandomSet() + { + ICollection visibleGroupedSets = ExpandedGroup != null && grouping.GroupItems.TryGetValue(ExpandedGroup, out var groupItems) + // In the case of grouping, users expect random to only operate on the expanded group. + // This is going to incur some overhead as we don't have a group-beatmapset mapping currently. + // + // If this becomes an issue, we could either store a mapping, or run the random algorithm many times + // using the `SetItems` method until we get a group HIT. + ? groupItems.Select(i => i.Model).OfType().ToArray() + // This is the fastest way to retrieve sets for randomisation. + : grouping.SetItems.Keys; + + GroupedBeatmapSet set; + + switch (randomAlgorithm.Value) + { + case RandomSelectAlgorithm.RandomPermutation: + { + ICollection notYetVisitedSets = + visibleGroupedSets.ExceptBy(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!), groupedSet => groupedSet.BeatmapSet).ToList(); + + if (!notYetVisitedSets.Any()) + { + previouslyVisitedRandomBeatmaps.ExceptWith(visibleGroupedSets.SelectMany(setUnderGrouping => setUnderGrouping.BeatmapSet.Beatmaps)); + notYetVisitedSets = visibleGroupedSets; + if (CurrentSelection is GroupedBeatmap groupedBeatmap) + notYetVisitedSets = notYetVisitedSets.ExceptBy([groupedBeatmap.Beatmap.BeatmapSet!], groupedSet => groupedSet.BeatmapSet).ToList(); + } + + if (notYetVisitedSets.Count == 0) + return false; + + set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count)); + break; + } + + case RandomSelectAlgorithm.Random: + set = visibleGroupedSets.ElementAt(RNG.Next(visibleGroupedSets.Count)); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + selectRecommendedDifficultyForBeatmapSet(set); + return true; + } + + public bool PreviousRandom() + { + var carouselItems = GetCarouselItems(); + + if (carouselItems?.Any() != true) + return false; + + while (randomHistory.Any()) + { + var previousBeatmap = randomHistory[^1]; + randomHistory.RemoveAt(randomHistory.Count - 1); + + // when going back through rewind history, we may no longer be in the same grouping mode. + // the user wants to go back to the beatmap first and foremost, so the most important thing is to find a panel that corresponds to the beatmap. + // going back to the same group is a nice-to-have, but a secondary concern. + var previousBeatmapItem = carouselItems.Where(i => i.Model is GroupedBeatmap gb && gb.Beatmap.Equals(previousBeatmap.Beatmap)) + .MaxBy(i => ((GroupedBeatmap)i.Model).Group == previousBeatmap.Group); + + if (previousBeatmapItem == null) + return false; + + if (CurrentSelection is GroupedBeatmap groupedBeatmap) + { + if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) + previouslyVisitedRandomBeatmaps.Remove(groupedBeatmap.Beatmap); + + if (CurrentSelectionItem == null) + playSpinSample(0); + else + playSpinSample(visiblePanelCountBetweenItems(previousBeatmapItem, CurrentSelectionItem)); + } + + RequestSelection((GroupedBeatmap)previousBeatmapItem.Model); + return true; + } + + return false; + } + + private double visiblePanelCountBetweenItems(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT); + + private void playSpinSample(double distance) + { + var chan = spinSample?.GetChannel(); + + if (chan != null) + { + chan.Frequency.Value = 1f + Math.Clamp(distance / 200, 0, 1); + chan.Play(); + } + + randomSelectSample?.Play(); + } + + #endregion + } + + /// + /// Defines a grouping header for a set of carousel items. + /// + public record GroupDefinition + { + /// + /// The order of this group in the carousel, sorted using ascending order. + /// + public int Order { get; } + + /// + /// The title of this group. + /// + public LocalisableString Title { get; } + + private readonly string uncasedTitle; + + public GroupDefinition(int order, LocalisableString title) + { + Order = order; + Title = title; + uncasedTitle = title.ToLower().GetLocalised(LocalisationParameters.DEFAULT); + } + + public virtual bool Equals(GroupDefinition? other) => uncasedTitle == other?.uncasedTitle; + + public override int GetHashCode() => HashCode.Combine(uncasedTitle); + } + + /// + /// Defines a grouping header for a set of carousel items grouped by star difficulty. + /// + public record StarDifficultyGroupDefinition(int Order, LocalisableString Title, StarDifficulty Difficulty) : GroupDefinition(Order, Title); + + /// + /// Defines a grouping header for a set of carousel items grouped by achieved rank. + /// + public record RankDisplayGroupDefinition(ScoreRank Rank) : GroupDefinition(-(int)Rank, Rank.GetLocalisableDescription()); + + /// + /// Defines a grouping header for a set of carousel items grouped by ranked status. + /// + public record RankedStatusGroupDefinition(int Order, BeatmapOnlineStatus Status) : GroupDefinition(Order, Status.GetLocalisableDescription()); + + /// + /// Used to represent a portion of a under a . + /// The purpose of this model is to support splitting beatmap sets apart when the active grouping mode demands it. + /// + public record GroupedBeatmapSet([UsedImplicitly] GroupDefinition? Group, BeatmapSetInfo BeatmapSet); + + /// + /// Used to represent a under a . + /// The purpose of this model is to support showing multiple copies of a beatmap, which can occur if a beatmap appears in multiple groups + /// (most prominently, collections group mode). + /// + public record GroupedBeatmap(GroupDefinition? Group, BeatmapInfo Beatmap); +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs new file mode 100644 index 0000000000..280db188ef --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -0,0 +1,486 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Graphics.Carousel; +using osu.Game.Scoring; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Utils; + +namespace osu.Game.Screens.SelectV2 +{ + public class BeatmapCarouselFilterGrouping : ICarouselFilter + { + public bool BeatmapSetsGroupedTogether { get; private set; } + + /// + /// The total number of beatmap difficulties displayed post filter. + /// + public int BeatmapItemsCount { get; private set; } + + public IDictionary ItemMap => itemMap; + + /// + /// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection. + /// + public IDictionary> SetItems => setMap; + + /// + /// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection. + /// + public IDictionary> GroupItems => groupMap; + + private Dictionary itemMap = new Dictionary(); + private Dictionary> setMap = new Dictionary>(); + private Dictionary> groupMap = new Dictionary>(); + + public required Func GetCriteria { get; init; } + public required Func> GetCollections { get; init; } + public required Func> GetLocalUserTopRanks { get; init; } + public required Func> GetFavouriteBeatmapSets { get; init; } + + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) + { + return await Task.Run(() => + { + // preallocate space for the new mappings using last known estimates + var newItemMap = new Dictionary(itemMap.Count); + var newSetMap = new Dictionary>(setMap.Count); + var newGroupMap = new Dictionary>(groupMap.Count); + + var criteria = GetCriteria(); + var newItems = new List(); + + BeatmapSetsGroupedTogether = ShouldGroupBeatmapsTogether(criteria); + + var groups = getGroups((List)items, criteria); + int displayedBeatmapsCount = 0; + + foreach (var (group, itemsInGroup) in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + CarouselItem? groupItem = null; + HashSet? currentGroupItems = null; + HashSet? currentSetItems = null; + BeatmapInfo? lastBeatmap = null; + + if (group != null) + { + newGroupMap[group] = currentGroupItems = new HashSet(); + + addItem(groupItem = new CarouselItem(group) + { + DrawHeight = PanelGroup.HEIGHT, + DepthLayer = -2, + }); + } + + foreach (var item in itemsInGroup) + { + cancellationToken.ThrowIfCancellationRequested(); + + var beatmap = (BeatmapInfo)item.Model; + + bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID; + var groupedBeatmapSet = new GroupedBeatmapSet(group, beatmap.BeatmapSet!); + + if (newBeatmapSet) + { + if (!newSetMap.TryGetValue(groupedBeatmapSet, out currentSetItems)) + newSetMap[groupedBeatmapSet] = currentSetItems = new HashSet(); + } + + if (BeatmapSetsGroupedTogether) + { + if (newBeatmapSet) + { + if (groupItem != null) + groupItem.NestedItemCount++; + + addItem(new CarouselItem(groupedBeatmapSet) + { + DrawHeight = PanelBeatmapSet.HEIGHT, + DepthLayer = -1 + }); + } + } + else + { + if (groupItem != null) + groupItem.NestedItemCount++; + } + + addItem(new CarouselItem(new GroupedBeatmap(group, beatmap)) + { + DrawHeight = BeatmapSetsGroupedTogether ? PanelBeatmap.HEIGHT : PanelBeatmapStandalone.HEIGHT, + }); + lastBeatmap = beatmap; + displayedBeatmapsCount++; + } + + void addItem(CarouselItem i) + { + newItems.Add(i); + + newItemMap[i.Model] = (i, newItems.Count - 1); + currentGroupItems?.Add(i); + currentSetItems?.Add(i); + + i.IsVisible = i.Model is GroupDefinition || (group == null && (i.Model is GroupedBeatmapSet || !BeatmapSetsGroupedTogether)); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + Interlocked.Exchange(ref itemMap, newItemMap); + Interlocked.Exchange(ref setMap, newSetMap); + Interlocked.Exchange(ref groupMap, newGroupMap); + BeatmapItemsCount = displayedBeatmapsCount; + return newItems; + }, cancellationToken).ConfigureAwait(false); + } + + public static bool ShouldGroupBeatmapsTogether(FilterCriteria criteria) + { + // In certain cases, we intentionally split out difficulties + // where it's more relevant or convenient to view them as individual items. + if (criteria.Sort == SortMode.Difficulty || criteria.Group == GroupMode.Difficulty) + return false; + if (criteria.Sort == SortMode.LastPlayed && criteria.Group == GroupMode.LastPlayed) + return false; + if (criteria.Group == GroupMode.RankAchieved) + return false; + + // In the majority case we group sets together for display. + return true; + } + + private List getGroups(List items, FilterCriteria criteria) + { + switch (criteria.Group) + { + case GroupMode.None: + return new List { new GroupMapping(null, items) }; + + case GroupMode.Artist: + return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Artist), items); + + case GroupMode.Author: + return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Author.Username), items); + + case GroupMode.Title: + return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Title), items); + + case GroupMode.DateAdded: + return getGroupsBy(b => defineGroupByDate(b.BeatmapSet!.DateAdded), items); + + case GroupMode.DateRanked: + return getGroupsBy(b => defineGroupByRankedDate(b.BeatmapSet!.DateRanked), items); + + case GroupMode.LastPlayed: + return getGroupsBy(b => + { + var date = b.LastPlayed; + + if (date == null) + return new GroupDefinition(int.MaxValue, "Never").Yield(); + + return defineGroupByDate(date.Value); + }, items); + + case GroupMode.RankedStatus: + return getGroupsBy(b => defineGroupByStatus(b.BeatmapSet!.Status), items); + + case GroupMode.BPM: + return getGroupsBy(b => defineGroupByBPM(FormatUtils.RoundBPM(b.BPM)), items); + + case GroupMode.Difficulty: + return getGroupsBy(b => defineGroupByStars(b.StarRating), items); + + case GroupMode.Length: + return getGroupsBy(b => defineGroupByLength(b.Length), items); + + case GroupMode.Source: + return getGroupsBy(b => defineGroupBySource(b.BeatmapSet!.Metadata.Source), items); + + case GroupMode.Collections: + { + var collections = GetCollections(); + return defineGroupsByCollection(items, collections); + } + + case GroupMode.MyMaps: + return getGroupsBy(b => defineGroupByOwnMaps(b, criteria.LocalUserId, criteria.LocalUserUsername), items); + + case GroupMode.RankAchieved: + { + var topRankMapping = GetLocalUserTopRanks(criteria); + return getGroupsBy(b => defineGroupByRankAchieved(b, topRankMapping), items); + } + + case GroupMode.Favourites: + { + var favouriteBeatmapSets = GetFavouriteBeatmapSets(); + return getGroupsBy(b => defineGroupByFavourites(b, favouriteBeatmapSets), items); + } + + default: + throw new ArgumentOutOfRangeException(); + } + } + + private List getGroupsBy(Func> defineGroups, List items) + { + var groups = new Dictionary(); + + foreach (var item in items) + { + foreach (var groupDefinition in defineGroups((BeatmapInfo)item.Model)) + { + if (!groups.TryGetValue(groupDefinition, out var group)) + group = groups[groupDefinition] = new GroupMapping(groupDefinition, []); + + group.ItemsInGroup.Add(item); + } + } + + return groups.Values + .OrderBy(g => g.Group!.Order) + .ThenBy(g => g.Group!.Title.ToString()) + .ToList(); + } + + private IEnumerable defineGroupAlphabetically(string name) + { + char firstChar = name.FirstOrDefault(); + + if (char.IsAsciiDigit(firstChar)) + return new GroupDefinition(int.MinValue, "0-9").Yield(); + + if (char.IsAsciiLetter(firstChar)) + return new GroupDefinition(char.ToUpperInvariant(firstChar) - 'A', char.ToUpperInvariant(firstChar).ToString()).Yield(); + + return new GroupDefinition(int.MaxValue, "Other").Yield(); + } + + private IEnumerable defineGroupByDate(DateTimeOffset date) + { + var now = DateTimeOffset.Now; + var elapsed = now - date; + + if (elapsed.TotalDays < 1) + return new GroupDefinition(0, "Today").Yield(); + + if (elapsed.TotalDays < 2) + return new GroupDefinition(1, "Yesterday").Yield(); + + if (elapsed.TotalDays < 7) + return new GroupDefinition(2, "Last week").Yield(); + + if (elapsed.TotalDays < 30) + return new GroupDefinition(3, "Last month").Yield(); + + if (elapsed.TotalDays < 60) + return new GroupDefinition(4, "1 month ago").Yield(); + + for (int i = 90; i <= 150; i += 30) + { + if (elapsed.TotalDays < i) + return new GroupDefinition(i, $"{i / 30 - 1} months ago").Yield(); + } + + return new GroupDefinition(151, "Over 5 months ago").Yield(); + } + + private IEnumerable defineGroupByRankedDate(DateTimeOffset? date) + { + if (date == null) + return new GroupDefinition(0, "Unranked").Yield(); + + return new GroupDefinition(-date.Value.Year, $"{date.Value.Year}").Yield(); + } + + private IEnumerable defineGroupByStatus(BeatmapOnlineStatus status) + { + switch (status) + { + case BeatmapOnlineStatus.Ranked: + case BeatmapOnlineStatus.Approved: + return new RankedStatusGroupDefinition(0, BeatmapOnlineStatus.Ranked).Yield(); + + case BeatmapOnlineStatus.Qualified: + return new RankedStatusGroupDefinition(1, status).Yield(); + + case BeatmapOnlineStatus.WIP: + return new RankedStatusGroupDefinition(2, status).Yield(); + + case BeatmapOnlineStatus.Pending: + return new RankedStatusGroupDefinition(3, status).Yield(); + + case BeatmapOnlineStatus.Graveyard: + return new RankedStatusGroupDefinition(4, status).Yield(); + + case BeatmapOnlineStatus.LocallyModified: + return new RankedStatusGroupDefinition(5, status).Yield(); + + case BeatmapOnlineStatus.None: + return new RankedStatusGroupDefinition(6, status).Yield(); + + case BeatmapOnlineStatus.Loved: + return new RankedStatusGroupDefinition(7, status).Yield(); + + default: + throw new ArgumentOutOfRangeException(nameof(status), status, null); + } + } + + private IEnumerable defineGroupByBPM(double bpm) + { + if (bpm < 60) + return new GroupDefinition(60, "Under 60 BPM").Yield(); + + for (int i = 70; i <= 300; i += 10) + { + if (bpm < i) + return new GroupDefinition(i, $"{i - 10} - {i} BPM").Yield(); + } + + return new GroupDefinition(301, "Over 300 BPM").Yield(); + } + + private IEnumerable defineGroupByStars(double stars) + { + // truncation is intentional - compare `FormatUtils.FormatStarRating()` + int starInt = (int)stars; + var starDifficulty = new StarDifficulty(starInt, 0); + + if (starInt == 0) + return new StarDifficultyGroupDefinition(0, "Below 1 Star", starDifficulty).Yield(); + + if (starInt == 1) + return new StarDifficultyGroupDefinition(1, "1 Star", starDifficulty).Yield(); + + if (starInt < 15) + return new StarDifficultyGroupDefinition(starInt, $"{starInt} Stars", starDifficulty).Yield(); + + return new StarDifficultyGroupDefinition(15, "Over 15 Stars", new StarDifficulty(15, 0)).Yield(); + } + + private IEnumerable defineGroupByLength(double length) + { + for (int i = 1; i < 6; i++) + { + if (length <= i * 60_000) + { + if (i == 1) + return new GroupDefinition(1, "1 minute or less").Yield(); + + return new GroupDefinition(i, $"{i} minutes or less").Yield(); + } + } + + if (length <= 10 * 60_000) + return new GroupDefinition(10, "10 minutes or less").Yield(); + + return new GroupDefinition(11, "Over 10 minutes").Yield(); + } + + private IEnumerable defineGroupBySource(string source) + { + if (string.IsNullOrEmpty(source)) + return new GroupDefinition(1, "Unsourced").Yield(); + + return new GroupDefinition(0, source).Yield(); + } + + private List defineGroupsByCollection(List carouselItems, List allCollections) + { + Dictionary groupMappings = new Dictionary(); + // this is a pre-built mapping of MD5s to a list of collections in which this MD5 is found in. + // the reason to pre-build this is that `BeatmapCollection.BeatmapMD5Hashes` is a list and therefore a naive implementation would be slow, + // particularly in edge cases where most beatmaps are in more than one collection. + Dictionary> md5ToCollectionsMap = new Dictionary>(); + + for (int i = 0; i < allCollections.Count; i++) + { + var collection = allCollections[i]; + // NOTE: the ordering of the incoming collection list is significant and needs to be preserved. + // the fallback to ordering by name cannot be relied on. + // see xmldoc of `BeatmapCarousel.GetAllCollections()`. + var groupDefinition = new GroupDefinition(i, collection.Name); + groupMappings[groupDefinition] = new GroupMapping(groupDefinition, []); + + foreach (string md5 in collection.BeatmapMD5Hashes) + { + if (!md5ToCollectionsMap.TryGetValue(md5, out var collections)) + md5ToCollectionsMap[md5] = collections = new List(); + + collections.Add(groupDefinition); + } + } + + var notInCollection = new GroupDefinition(int.MaxValue, "Not in collection"); + groupMappings[notInCollection] = new GroupMapping(notInCollection, []); + + foreach (var item in carouselItems) + { + var beatmap = (BeatmapInfo)item.Model; + + // as a side note, even reading the `MD5Hash` off a realm model is slow if done enough times, + // so it definitely helps that thanks to the mapping it needs to only be retrieved once + if (md5ToCollectionsMap.TryGetValue(beatmap.MD5Hash, out var collections)) + { + foreach (var collection in collections) + groupMappings[collection].ItemsInGroup.Add(item); + } + else + groupMappings[notInCollection].ItemsInGroup.Add(item); + } + + return groupMappings.Values + // safety against potentially empty eagerly-initialised groups + // (could happen if user has a collection with MD5s of maps that aren't locally available) + .Where(mapping => mapping.ItemsInGroup.Count > 0) + .OrderBy(mapping => mapping.Group!.Order) + .ToList(); + } + + private IEnumerable defineGroupByOwnMaps(BeatmapInfo beatmap, int? localUserId, string? localUserUsername) + { + var author = beatmap.BeatmapSet!.Metadata.Author; + + if (author.OnlineID == localUserId || (author.OnlineID <= 1 && author.Username == localUserUsername)) + return new GroupDefinition(0, "My maps").Yield(); + + // discard beatmaps not owned by the user. + return []; + } + + private IEnumerable defineGroupByRankAchieved(BeatmapInfo beatmap, IReadOnlyDictionary topRankMapping) + { + if (topRankMapping.TryGetValue(beatmap.ID, out var rank)) + return new RankDisplayGroupDefinition(rank).Yield(); + + return new GroupDefinition(int.MaxValue, "Unplayed").Yield(); + } + + private IEnumerable defineGroupByFavourites(BeatmapInfo beatmap, HashSet favouriteBeatmapSets) + { + if (beatmap.BeatmapSet?.OnlineID > 0 && favouriteBeatmapSets.Contains(beatmap.BeatmapSet.OnlineID)) + return new GroupDefinition(0, "Favourites").Yield(); + + return []; + } + + private record GroupMapping(GroupDefinition? Group, List ItemsInGroup); + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs new file mode 100644 index 0000000000..2a132a8a45 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Carousel; +using osu.Game.Screens.Select; +using osu.Game.Utils; + +namespace osu.Game.Screens.SelectV2 +{ + public class BeatmapCarouselFilterMatching : ICarouselFilter + { + private readonly Func getCriteria; + + public int BeatmapItemsCount { get; private set; } + + public BeatmapCarouselFilterMatching(Func getCriteria) + { + this.getCriteria = getCriteria; + } + + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => + { + var criteria = getCriteria(); + + return matchItems(items, criteria).ToList(); + }, cancellationToken).ConfigureAwait(false); + + private IEnumerable matchItems(IEnumerable items, FilterCriteria criteria) + { + int countMatching = 0; + + foreach (var item in items) + { + var beatmap = (BeatmapInfo)item.Model; + + if (beatmap.Hidden) + continue; + + if (!checkCriteriaMatch(beatmap, criteria)) + continue; + + countMatching++; + yield return item; + } + + BeatmapItemsCount = countMatching; + } + + private static bool checkCriteriaMatch(BeatmapInfo beatmap, FilterCriteria criteria) + { + bool match = criteria.Ruleset == null || beatmap.AllowGameplayWithRuleset(criteria.Ruleset!, criteria.AllowConvertedBeatmaps); + + if (beatmap.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) + { + // only check ruleset equality or convertability for selected beatmap + return match; + } + + if (!match) return false; + + if (criteria.SearchTerms.Length > 0) + { + match = beatmap.Match(criteria.SearchTerms); + + // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. + // this should be done after text matching so we can prioritise matching numbers in metadata. + if (!match && criteria.SearchNumber.HasValue) + { + match = (beatmap.OnlineID == criteria.SearchNumber.Value) || + (beatmap.BeatmapSet?.OnlineID == criteria.SearchNumber.Value); + } + } + + if (!match) return false; + + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(beatmap.StarRating.FloorToDecimalDigits(2)); + match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(beatmap.Difficulty.ApproachRate); + match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(beatmap.Difficulty.DrainRate); + match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(beatmap.Difficulty.CircleSize); + match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(beatmap.Difficulty.OverallDifficulty); + match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(beatmap.Length); + match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(beatmap.LastPlayed ?? DateTimeOffset.MinValue); + match &= !criteria.DateRanked.HasFilter || (beatmap.BeatmapSet?.DateRanked != null && criteria.DateRanked.IsInRange(beatmap.BeatmapSet.DateRanked.Value)); + match &= !criteria.DateSubmitted.HasFilter || (beatmap.BeatmapSet?.DateSubmitted != null && criteria.DateSubmitted.IsInRange(beatmap.BeatmapSet.DateSubmitted.Value)); + match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(beatmap.BPM); + + match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(beatmap.BeatDivisor); + match &= !criteria.OnlineStatus.HasFilter || criteria.OnlineStatus.IsInRange(beatmap.Status); + + if (!match) return false; + + match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(beatmap.Metadata.Author.Username); + + if (criteria.Artist.HasFilter) + { + if (criteria.Artist.ExcludeTerm) + match &= criteria.Artist.Matches(beatmap.Metadata.Artist) && criteria.Artist.Matches(beatmap.Metadata.ArtistUnicode); + else + match &= criteria.Artist.Matches(beatmap.Metadata.Artist) || criteria.Artist.Matches(beatmap.Metadata.ArtistUnicode); + } + + if (criteria.Title.HasFilter) + { + if (criteria.Title.ExcludeTerm) + match &= criteria.Title.Matches(beatmap.Metadata.Title) && criteria.Title.Matches(beatmap.Metadata.TitleUnicode); + else + match &= criteria.Title.Matches(beatmap.Metadata.Title) || criteria.Title.Matches(beatmap.Metadata.TitleUnicode); + } + + match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(beatmap.DifficultyName); + match &= !criteria.Source.HasFilter || criteria.Source.Matches(beatmap.Metadata.Source); + + if (criteria.UserTags.Any()) + { + foreach (var tagFilter in criteria.UserTags) + { + if (tagFilter.ExcludeTerm) + { + // if `ExcludeTerm` is true, `Matches()` will return true if a user tag *doesn't match* the excluded term. + // thus, every user tag must pass this filter. + foreach (string tag in beatmap.Metadata.UserTags) + match &= tagFilter.Matches(tag); + } + else + { + // if `ExcludeTerm` is false, `Matches()` will return true if a user tag *matches* the expected term. + // the expected behaviour is that a beatmap should be displayed if at least one of the user tags passes the filter. + bool anyTagMatched = false; + + foreach (string tag in beatmap.Metadata.UserTags) + anyTagMatched |= tagFilter.Matches(tag); + + match &= anyTagMatched; + } + } + } + + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(beatmap.StarRating); + + if (!match) return false; + + match &= criteria.CollectionBeatmapMD5Hashes?.Contains(beatmap.MD5Hash) ?? true; + if (match && criteria.RulesetCriteria != null) + match &= criteria.RulesetCriteria.Matches(beatmap, criteria); + + if (match && criteria.HasOnlineID == true) + match &= beatmap.OnlineID >= 0; + + if (match && criteria.BeatmapSetId != null) + match &= criteria.BeatmapSetId == beatmap.BeatmapSet?.OnlineID; + + return match; + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs new file mode 100644 index 0000000000..e9d65f7108 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.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; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Carousel; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Utils; + +namespace osu.Game.Screens.SelectV2 +{ + public class BeatmapCarouselFilterSorting : ICarouselFilter + { + public int BeatmapItemsCount { get; private set; } + + private readonly Func getCriteria; + + public BeatmapCarouselFilterSorting(Func getCriteria) + { + this.getCriteria = getCriteria; + } + + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => + { + var criteria = getCriteria(); + + bool groupedSets = BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria); + + BeatmapItemsCount = items.Count(); + + return items.Order(Comparer.Create((a, b) => + { + var ab = (BeatmapInfo)a.Model; + var bb = (BeatmapInfo)b.Model; + + if (groupedSets) + { + if (ab.BeatmapSet!.Equals(bb.BeatmapSet)) + return compareDifficulty(ab, bb, criteria.Sort); + + // If we're grouping by sets, all fallback sorts need to be aggregates for the set. + return compare(ab, bb, criteria.Sort, aggregate: true); + } + + return compare(ab, bb, criteria.Sort, aggregate: false); + })).ToList(); + }, cancellationToken).ConfigureAwait(false); + + private static int compare(BeatmapInfo a, BeatmapInfo b, SortMode sort, bool aggregate) + { + int comparison; + + switch (sort) + { + case SortMode.Artist: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(a.BeatmapSet!.Metadata.Artist, b.BeatmapSet!.Metadata.Artist); + if (comparison == 0) + goto case SortMode.Title; + break; + + case SortMode.Title: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(a.BeatmapSet!.Metadata.Title, b.BeatmapSet!.Metadata.Title); + break; + + case SortMode.Author: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(a.BeatmapSet!.Metadata.Author.Username, b.BeatmapSet!.Metadata.Author.Username); + break; + + case SortMode.Source: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(a.BeatmapSet!.Metadata.Source, b.BeatmapSet!.Metadata.Source); + break; + + case SortMode.Difficulty: + comparison = a.StarRating.CompareTo(b.StarRating); + break; + + case SortMode.DateAdded: + comparison = b.BeatmapSet!.DateAdded.CompareTo(a.BeatmapSet!.DateAdded); + break; + + case SortMode.DateRanked: + comparison = Nullable.Compare(b.BeatmapSet!.DateRanked, a.BeatmapSet!.DateRanked); + break; + + case SortMode.DateSubmitted: + comparison = Nullable.Compare(b.BeatmapSet!.DateSubmitted, a.BeatmapSet!.DateSubmitted); + break; + + case SortMode.LastPlayed: + if (aggregate) + comparison = compareUsingAggregateMax(b, a, static b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds()); + else + comparison = Nullable.Compare(b.LastPlayed, a.LastPlayed); + break; + + case SortMode.BPM: + if (aggregate) + comparison = compareUsingAggregateMax(a, b, static b => b.BPM); + else + comparison = a.BPM.CompareTo(b.BPM); + break; + + case SortMode.Length: + if (aggregate) + comparison = compareUsingAggregateMax(a, b, static b => b.Length); + else + comparison = a.Length.CompareTo(b.Length); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + // If the initial sort could not differentiate, attempt to use DateAdded to order sets in a stable fashion. + // The directionality of this matches the current SortMode.DateAdded, but we may want to reconsider if that becomes a user decision (ie. asc / desc). + if (comparison == 0) + comparison = b.BeatmapSet!.DateAdded.CompareTo(a.BeatmapSet!.DateAdded); + + // If DateAdded fails to break the tie, fallback to our internal GUID for stability. + // This basically means it's a stable random sort. + if (comparison == 0) + comparison = b.BeatmapSet!.ID.CompareTo(a.BeatmapSet!.ID); + + return comparison; + } + + private static int compareDifficulty(BeatmapInfo a, BeatmapInfo b, SortMode sort) + { + int comparison = a.Ruleset.CompareTo(b.Ruleset); + + if (comparison == 0) + comparison = a.StarRating.CompareTo(b.StarRating); + + return comparison; + } + + private static int compareUsingAggregateMax(BeatmapInfo a, BeatmapInfo b, Func func) + { + var aMatchedBeatmaps = a.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden); + var bMatchedBeatmaps = b.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden); + + bool aAny = aMatchedBeatmaps.Any(); + bool bAny = bMatchedBeatmaps.Any(); + + if (!aAny && !bAny) return 0; + if (!aAny) return -1; + if (!bAny) return 1; + + return aMatchedBeatmaps.Max(func).CompareTo(bMatchedBeatmaps.Max(func)); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.Header.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.Header.cs new file mode 100644 index 0000000000..f4a223985d --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.Header.cs @@ -0,0 +1,233 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Leaderboards; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapDetailsArea + { + public partial class Header : CompositeDrawable + { + private WedgeSelector tabControl = null!; + private FillFlowContainer leaderboardControls = null!; + + private ShearedDropdown scopeDropdown = null!; + private ShearedDropdown sortDropdown = null!; + private ShearedToggleButton selectedModsToggle = null!; + + public IBindable Type => tabControl.Current; + + public IBindable Scope => scopeDropdown.Current; + + private readonly Bindable configDetailTab = new Bindable(); + + public IBindable Sorting => sortDropdown.Current; + + private readonly Bindable configLeaderboardSortMode = new Bindable(); + + public IBindable FilterBySelectedMods => selectedModsToggle.Active; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 5f }, + Children = new Drawable[] + { + tabControl = new WedgeSelector(20f) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 200, + Height = 22, + Margin = new MarginPadding { Top = 2f }, + IsSwitchable = true, + }, + leaderboardControls = new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + Height = 30, + Spacing = new Vector2(5f, 0f), + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Left = 258 }, + Children = new Drawable[] + { + selectedModsToggle = new ShearedToggleButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Text = UserInterfaceStrings.SelectedMods, + Height = 30f, + // Eyeballed to make spacing match. Because shear is silly and implemented in different ways between dropdown and button. + Margin = new MarginPadding { Left = -9.2f }, + }, + sortDropdown = new ShearedDropdown(BeatmapLeaderboardWedgeStrings.Sort) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0.4f, + Items = Enum.GetValues(), + }, + scopeDropdown = new ScopeDropdown + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0.4f, + Current = { Value = BeatmapLeaderboardScope.Global }, + }, + }, + }, + }, + }, + }; + + config.BindWith(OsuSetting.BeatmapDetailTab, configDetailTab); + config.BindWith(OsuSetting.BeatmapLeaderboardSortMode, configLeaderboardSortMode); + config.BindWith(OsuSetting.BeatmapDetailModsFilter, selectedModsToggle.Active); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + scopeDropdown.Current.Value = tryMapDetailTabToLeaderboardScope(configDetailTab.Value) ?? scopeDropdown.Current.Value; + scopeDropdown.Current.BindValueChanged(_ => updateConfigDetailTab()); + + tabControl.Current.Value = configDetailTab.Value == BeatmapDetailTab.Details ? Selection.Details : Selection.Ranking; + tabControl.Current.BindValueChanged(v => + { + leaderboardControls.FadeTo(v.NewValue == Selection.Ranking ? 1 : 0, 300, Easing.OutQuint); + updateConfigDetailTab(); + }, true); + + scopeDropdown.Current.BindValueChanged(scope => + { + sortDropdown.Current.Disabled = false; + + if (scope.NewValue == BeatmapLeaderboardScope.Local) + { + sortDropdown.Current.BindTo(configLeaderboardSortMode); + } + else + { + // future implementation when we have web-side support. + sortDropdown.Current.UnbindFrom(configLeaderboardSortMode); + sortDropdown.Current.Value = LeaderboardSortMode.Score; + sortDropdown.Current.Disabled = true; + } + }, true); + } + + #region Reading / writing state from / to configuration + + private void updateConfigDetailTab() + { + switch (tabControl.Current.Value) + { + case Selection.Details: + configDetailTab.Value = BeatmapDetailTab.Details; + return; + + case Selection.Ranking: + configDetailTab.Value = mapLeaderboardScopeToDetailTab(scopeDropdown.Current.Value); + return; + + default: + throw new ArgumentOutOfRangeException(nameof(tabControl.Current.Value), tabControl.Current.Value, null); + } + } + + private static BeatmapLeaderboardScope? tryMapDetailTabToLeaderboardScope(BeatmapDetailTab tab) + { + switch (tab) + { + case BeatmapDetailTab.Local: + return BeatmapLeaderboardScope.Local; + + case BeatmapDetailTab.Country: + return BeatmapLeaderboardScope.Country; + + case BeatmapDetailTab.Global: + return BeatmapLeaderboardScope.Global; + + case BeatmapDetailTab.Friends: + return BeatmapLeaderboardScope.Friend; + + case BeatmapDetailTab.Team: + return BeatmapLeaderboardScope.Team; + + default: + return null; + } + } + + private static BeatmapDetailTab mapLeaderboardScopeToDetailTab(BeatmapLeaderboardScope scope) + { + switch (scope) + { + case BeatmapLeaderboardScope.Local: + return BeatmapDetailTab.Local; + + case BeatmapLeaderboardScope.Country: + return BeatmapDetailTab.Country; + + case BeatmapLeaderboardScope.Global: + return BeatmapDetailTab.Global; + + case BeatmapLeaderboardScope.Friend: + return BeatmapDetailTab.Friends; + + case BeatmapLeaderboardScope.Team: + return BeatmapDetailTab.Team; + + default: + throw new ArgumentOutOfRangeException(nameof(scope), scope, null); + } + } + + #endregion + + public enum Selection + { + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Details))] + Details, + + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Ranking))] + Ranking, + } + + private partial class ScopeDropdown : ShearedDropdown + { + public ScopeDropdown() + : base(BeatmapLeaderboardWedgeStrings.Scope) + { + Items = Enum.GetValues(); + } + + protected override LocalisableString GenerateItemText(BeatmapLeaderboardScope item) => item.GetLocalisableDescription(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs new file mode 100644 index 0000000000..b5cdeee792 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs @@ -0,0 +1,138 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapDetailsArea + { + public partial class WedgeSelector : TabControl + where T : struct, Enum + { + private Circle strip = null!; + + protected override Dropdown? CreateDropdown() => null; + + protected override TabItem CreateTabItem(T value) => new TabItem(value); + + protected new TabItem SelectedTab => (TabItem)base.SelectedTab; + + public WedgeSelector(float spacing) + { + TabContainer.Spacing = new Vector2(spacing, 0f); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AddInternal(strip = new Circle + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Height = 2, + Colour = colourProvider.Highlight1, + }); + + foreach (var type in Enum.GetValues()) + AddItem(type); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateDisplay()); + + ScheduleAfterChildren(() => + { + updateDisplay(); + FinishTransforms(true); + }); + } + + private void updateDisplay() + { + strip.MoveToX(SelectedTab.Text.ToSpaceOfOtherDrawable(Vector2.Zero, this).X, 300, Easing.OutQuint); + strip.ResizeWidthTo(SelectedTab.Text.Width, 0, Easing.OutQuint); + } + + protected partial class TabItem : TabItem + { + private Sample? selectSample; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public readonly OsuSpriteText Text; + + public TabItem(T value) + : base(value) + { + AutoSizeAxes = Axes.Both; + + Children = new Drawable[] + { + Text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = value.GetLocalisableDescription(), + Font = OsuFont.Style.Body, + }, + new HoverSounds(HoverSampleSet.TabSelect) + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + selectSample = audio.Samples.Get(@"UI/tabselect-select"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + protected override void OnActivatedByUser() => selectSample?.Play(); + + protected override void OnActivated() => updateDisplay(); + + protected override void OnDeactivated() => updateDisplay(); + + protected override bool OnHover(HoverEvent e) + { + updateDisplay(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) => updateDisplay(); + + private void updateDisplay() + { + if (Active.Value || IsHovered) + Text.FadeColour(colourProvider.Content1, 300, Easing.OutQuint); + else + Text.FadeColour(colourProvider.Content2, 300, Easing.OutQuint); + + Text.Font = Text.Font.With(weight: Active.Value ? FontWeight.SemiBold : FontWeight.Regular); + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs new file mode 100644 index 0000000000..7a2068b0cf --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// The left portion of the song select screen which houses the metadata or leaderboards wedge, along with controls + /// to switch between them and adjust specifics. + /// + public partial class BeatmapDetailsArea : VisibilityContainer + { + private Header header = null!; + private Container contentContainer = null!; + + public BeatmapDetailsArea() + { + RelativeSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load() + { + const float header_height = 35f; + + InternalChildren = new Drawable[] + { + new ShearAligningWrapper(header = new Header + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = header_height, + }), + new ShearAligningWrapper(contentContainer = new Container + { + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Top = header_height }, + RelativeSizeAxes = Axes.Both, + }) + { + Depth = 1f, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + header.Type.BindValueChanged(_ => updateDisplay(), true); + } + + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(-150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + private Drawable? currentContent; + + private void updateDisplay() + { + if (currentContent != null) + { + currentContent.Hide(); + currentContent.Expire(); + } + + switch (header.Type.Value) + { + default: + case Header.Selection.Details: + currentContent = new BeatmapMetadataWedge(); + break; + + case Header.Selection.Ranking: + currentContent = new BeatmapLeaderboardWedge + { + Scope = { BindTarget = header.Scope }, + Sorting = { BindTarget = header.Sorting }, + FilterBySelectedMods = { BindTarget = header.FilterBySelectedMods }, + }; + + break; + } + + contentContainer.Add(currentContent); + currentContent.Show(); + } + + public void Refresh() + { + if (currentContent is BeatmapLeaderboardWedge leaderboardWedge) + leaderboardWedge.RefetchScores(); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.Tooltip.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.Tooltip.cs new file mode 100644 index 0000000000..1f92699887 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.Tooltip.cs @@ -0,0 +1,462 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +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.Localisation; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapLeaderboardScore + { + public partial class LeaderboardScoreTooltip : VisibilityContainer, ITooltip + { + private const float spacing = 20f; + + private DateAndStatisticsPanel dateAndStatistics = null!; + private ModsPanel modsPanel = null!; + private TotalScoreRankPanel totalScoreRankPanel = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider; + + public LeaderboardScoreTooltip(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + Width = 170; + AutoSizeAxes = Axes.Y; + + InternalChild = new ReverseChildIDFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, -spacing), + Children = new Drawable[] + { + dateAndStatistics = new DateAndStatisticsPanel(), + modsPanel = new ModsPanel(), + totalScoreRankPanel = new TotalScoreRankPanel(), + }, + }; + } + + private ScoreInfo? lastContent; + + public void SetContent(ScoreInfo content) + { + if (lastContent != null && lastContent.Equals(content)) + return; + + dateAndStatistics.Score = content; + modsPanel.Score = content; + totalScoreRankPanel.Score = content; + lastContent = content; + } + + protected override void PopIn() => this.FadeIn(300, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(300, Easing.OutQuint); + public void Move(Vector2 pos) => Position = pos; + + private partial class DateAndStatisticsPanel : CompositeDrawable + { + private OsuSpriteText absoluteDate = null!; + private DrawableDate relativeDate = null!; + private FillFlowContainer statistics = null!; + + private readonly Bindable prefer24HourTime = new Bindable(); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private ScoreInfo score = null!; + + public ScoreInfo Score + { + get => score; + set + { + score = value; + + updateAbsoluteDate(); + relativeDate.Date = value.Date; + + var judgementsStatistics = value.GetStatisticsForDisplay().Select(s => + new StatisticRow(s.DisplayName.ToUpper(), s.Count.ToLocalisableString("N0"), colours.ForHitResult(s.Result))); + + double multiplier = 1.0; + + foreach (var mod in value.Mods) + multiplier *= mod.ScoreMultiplier; + + var generalStatistics = new[] + { + new StatisticRow(BeatmapsetsStrings.ShowScoreboardHeadersCombo, value.MaxCombo.ToLocalisableString(@"0\x")), + new StatisticRow(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, value.Accuracy.FormatAccuracy()), + new PerformanceStatisticRow(BeatmapsetsStrings.ShowScoreboardHeaderspp.ToUpper(), score), + Empty().With(d => d.Height = 20), + new StatisticRow(ModSelectOverlayStrings.ScoreMultiplier, ModUtils.FormatScoreMultiplier(multiplier)), + }; + + statistics.ChildrenEnumerable = judgementsStatistics + .Append(Empty().With(d => d.Height = 20)) + .Concat(generalStatistics); + } + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + CornerRadius = corner_radius; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 4f, + }; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Margin = new MarginPadding { Top = 8f }, + Children = new Drawable[] + { + absoluteDate = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + UseFullGlyphHeight = false, + }, + relativeDate = new DrawableDate(default, OsuFont.Style.Caption1.Size) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = colourProvider.Content2, + UseFullGlyphHeight = false, + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + CornerRadius = corner_radius, + Masking = true, + Margin = new MarginPadding { Top = 4f }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + statistics = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 2f), + Padding = new MarginPadding(8f), + }, + }, + }, + }, + }, + }; + + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + prefer24HourTime.BindValueChanged(_ => updateAbsoluteDate(), true); + } + + private void updateAbsoluteDate() + => absoluteDate.Text = score.Date.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt"); + } + + public partial class StatisticRow : CompositeDrawable + { + private readonly OsuSpriteText labelText; + protected readonly OsuSpriteText ValueText; + + private readonly Color4? colour; + + public StatisticRow(LocalisableString label, LocalisableString value, Color4? colour = null) + { + this.colour = colour; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + labelText = new OsuSpriteText + { + Text = label, + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + }, + ValueText = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Text = value, + Colour = Color4.White, + Font = OsuFont.Style.Caption2, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + labelText.Colour = colour ?? colourProvider.Content2; + ValueText.Colour = Interpolation.ValueAt(0.85f, colourProvider.Content1, colour ?? colourProvider.Content1, 0, 1); + } + } + + public partial class PerformanceStatisticRow : StatisticRow + { + private readonly ScoreInfo score; + + public PerformanceStatisticRow(LocalisableString label, ScoreInfo score) + : base(label, @"0pp") + { + this.score = score; + } + + [BackgroundDependencyLoader] + private void load(BeatmapDifficultyCache difficultyCache, CancellationToken? cancellationToken) + { + if (score.PP.HasValue) + { + setPerformanceValue(score, score.PP.Value); + return; + } + + Task.Run(async () => + { + var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken ?? default).ConfigureAwait(false); + var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); + + // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. + if (attributes?.DifficultyAttributes == null || performanceCalculator == null) + return; + + var result = await performanceCalculator.CalculateAsync(score, attributes.Value.DifficultyAttributes, cancellationToken ?? CancellationToken.None).ConfigureAwait(false); + + Schedule(() => setPerformanceValue(score, result.Total)); + }, cancellationToken ?? default); + } + + private void setPerformanceValue(ScoreInfo scoreInfo, double pp) + { + int ppValue = (int)Math.Round(pp, MidpointRounding.AwayFromZero); + ValueText.Text = LocalisableString.Interpolate(@$"{ppValue:N0}pp"); + + if (!scoreInfo.BeatmapInfo!.Status.GrantsPerformancePoints() || hasUnrankedMods(scoreInfo)) + Alpha = 0.5f; + else + Alpha = 1f; + } + + private static bool hasUnrankedMods(ScoreInfo scoreInfo) + { + IEnumerable modsToCheck = scoreInfo.Mods; + + if (scoreInfo.IsLegacyScore) + modsToCheck = modsToCheck.Where(m => m is not ModClassic); + + return modsToCheck.Any(m => !m.Ranked); + } + } + + private partial class ModsPanel : CompositeDrawable + { + private FillFlowContainer modsFlow = null!; + + public ScoreInfo Score + { + set + { + var mods = value.Mods; + + if (!mods.Any()) + Hide(); + else + { + Show(); + + modsFlow.ChildrenEnumerable = mods.AsOrdered().Select(m => new ModIcon(m) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.3f), + }); + } + } + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + CornerRadius = corner_radius; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 4f, + }; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Transparent, + }, + modsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = 6f, Top = 6f + spacing }, + Padding = new MarginPadding { Horizontal = 16f }, + Spacing = new Vector2(2f, -4f), + }, + }; + } + } + + public partial class TotalScoreRankPanel : CompositeDrawable + { + private Box rankBackground = null!; + private Container rankContainer = null!; + private OsuSpriteText totalScore = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + public ScoreInfo Score + { + set + { + rankBackground.Colour = ColourInfo.GradientVertical( + OsuColour.ForRank(value.Rank).Opacity(0f), + OsuColour.ForRank(value.Rank).Opacity(0.5f)); + rankContainer.Child = new DrawableRank(value.Rank); + totalScore.Current = scoreManager.GetBindableTotalScoreString(value); + } + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + CornerRadius = corner_radius; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 4f, + }; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#353535"), + }, + rankBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + rankContainer = new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(25f, 14f), + Margin = new MarginPadding { Bottom = 5f }, + }, + totalScore = new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding { Bottom = 25f, Top = 10f + spacing }, + Font = OsuFont.Style.Subtitle.With(weight: FontWeight.Light, fixedWidth: true), + Spacing = new Vector2(-1.5f), + UseFullGlyphHeight = false, + }, + }; + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs new file mode 100644 index 0000000000..5013150f05 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs @@ -0,0 +1,744 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Select; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; +using CommonStrings = osu.Game.Localisation.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public sealed partial class BeatmapLeaderboardScore : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip + { + public const int HEIGHT = 50; + + public readonly ScoreInfo Score; + + public Bindable> SelectedMods = new Bindable>(); + + /// + /// A function determining whether each mod in the score can be selected. + /// A return value of means that the mod can be selected in the current context. + /// A return value of means that the mod cannot be selected in the current context. + /// + public Func IsValidMod { get; set; } = _ => true; + + public int? Rank { get; init; } + public HighlightType? Highlight { get; init; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private Clipboard? clipboard { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private const float expanded_right_content_width = 200; + private const float grade_width = 35; + private const float username_min_width = 120; + private const float statistics_regular_min_width = 165; + private const float statistics_compact_min_width = 90; + private const float rank_label_width = 40; + + private const int corner_radius = 10; + private const int transition_duration = 200; + + private static readonly Color4 personal_best_gradient_left = Color4Extensions.FromHex("#66FFCC"); + private static readonly Color4 personal_best_gradient_right = Color4Extensions.FromHex("#51A388"); + + private Colour4 foregroundColour; + private Colour4 backgroundColour; + private ColourInfo totalScoreBackgroundGradient; + + private IBindable scoringMode { get; set; } = null!; + + private Box background = null!; + private Box foreground = null!; + + private ClickableAvatar innerAvatar = null!; + + private Container centreContent = null!; + private Container rightContent = null!; + + private FillFlowContainer modsContainer = null!; + + private Box totalScoreBackground = null!; + + private FillFlowContainer statisticsContainer = null!; + private Container highlightGradient = null!; + private Container rankLabelStandalone = null!; + private Container rankLabelOverlay = null!; + + private readonly bool sheared; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + var inputRectangle = DrawRectangle; + + inputRectangle = inputRectangle.Inflate(new MarginPadding { Vertical = BeatmapLeaderboardWedge.SPACING_BETWEEN_SCORES / 2 }); + + return inputRectangle.Contains(ToLocalSpace(screenSpacePos)); + } + + public BeatmapLeaderboardScore(ScoreInfo score, bool sheared = true) + { + Score = score; + + this.sheared = sheared; + + Shear = sheared ? OsuGame.SHEAR : Vector2.Zero; + RelativeSizeAxes = Axes.X; + Height = HEIGHT; + } + + [BackgroundDependencyLoader] + private void load() + { + foregroundColour = colourProvider.Background5; + backgroundColour = colourProvider.Background3; + totalScoreBackgroundGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), backgroundColour); + + Child = new Container + { + Masking = true, + CornerRadius = corner_radius, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + Alpha = 0.4f, + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + rankLabelStandalone = new Container + { + Width = rank_label_width, + RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + highlightGradient = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = -10f }, + Alpha = Highlight != null ? 1 : 0, + Colour = getHighlightColour(Highlight), + Child = new Box { RelativeSizeAxes = Axes.Both }, + }, + new RankLabel(Rank, sheared, darkText: Highlight == HighlightType.Own) + { + RelativeSizeAxes = Axes.Both, + } + }, + }, + centreContent = new Container + { + Name = @"Centre container", + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Masking = true, + CornerRadius = corner_radius, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + foreground = new Box + { + Alpha = 0.4f, + RelativeSizeAxes = Axes.Both, + Colour = foregroundColour + }, + new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + User = Score.User, + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Colour = ColourInfo.GradientHorizontal(Colour4.White.Opacity(0.5f), Colour4.FromHex(@"222A27").Opacity(1)), + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Both, + CornerRadius = corner_radius, + Masking = true, + Children = new Drawable[] + { + new DelayedLoadWrapper(innerAvatar = new ClickableAvatar(Score.User) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.1f), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + RelativeSizeAxes = Axes.Both, + }) + { + RelativeSizeAxes = Axes.None, + Size = new Vector2(HEIGHT) + }, + rankLabelOverlay = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.5f), + }, + new RankLabel(Rank, sheared, false) + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + } + }, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = corner_radius }, + Children = new Drawable[] + { + new FillFlowContainer + { + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new UpdateableFlag(Score.User.CountryCode) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(20, 14), + }, + new UpdateableTeamFlag(Score.User.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(30, 15), + }, + new DateLabel(Score.Date) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = colourProvider.Content2, + UseFullGlyphHeight = false, + } + } + }, + new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Text = Score.User.Username, + Font = OsuFont.Style.Heading2, + } + } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Child = statisticsContainer = new FillFlowContainer + { + Name = @"Statistics container", + Padding = new MarginPadding { Right = 10 }, + Spacing = new Vector2(20, 0), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ScoreComponentLabel(BeatmapsetsStrings.ShowScoreboardHeadersCombo.ToUpper(), $"{Score.MaxCombo.ToString()}x", + Score.MaxCombo == Score.GetMaximumAchievableCombo(), 60), + new ScoreComponentLabel(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy.ToUpper(), Score.DisplayAccuracy, Score.Accuracy == 1, + 55), + }, + Alpha = 0, + } + } + } + } + }, + }, + }, + }, + rightContent = new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Name = @"Right content", + RelativeSizeAxes = Axes.Y, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = grade_width }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(Score.Rank)), + }, + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Width = grade_width, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = OsuColour.ForRank(Score.Rank), + }, + new TrianglesV2 + { + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + SpawnRatio = 2, + Velocity = 0.7f, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(Score.Rank).Darken(0.2f)), + }, + new Container + { + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + Width = grade_width, + Child = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-2), + Colour = DrawableRank.GetRankLetterColour(Score.Rank), + Font = OsuFont.Numeric.With(size: 14), + Text = DrawableRank.GetRankLetter(Score.Rank), + ShadowColour = Color4.Black.Opacity(0.3f), + ShadowOffset = new Vector2(0, 0.08f), + Shadow = true, + UseFullGlyphHeight = false, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Right = grade_width }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = corner_radius, + Children = new Drawable[] + { + totalScoreBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = totalScoreBackgroundGradient, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(Score.Rank).Opacity(0.5f)), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = corner_radius }, + Spacing = new Vector2(0f, -2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + UseFullGlyphHeight = false, + Current = scoreManager.GetBindableTotalScoreString(Score), + Spacing = new Vector2(-1.5f), + Font = OsuFont.Style.Subtitle.With(weight: FontWeight.Light, fixedWidth: true), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + }, + modsContainer = new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(-10, 0), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + }, + } + } + } + } + } + } + }, + } + } + }; + innerAvatar.OnLoadComplete += d => d.FadeInFromZero(200); + } + + private ColourInfo getHighlightColour(HighlightType? highlightType, float lightenAmount = 0) + { + switch (highlightType) + { + case HighlightType.Own: + return ColourInfo.GradientHorizontal(personal_best_gradient_left.Lighten(lightenAmount), personal_best_gradient_right.Lighten(lightenAmount)); + + case HighlightType.Friend: + return ColourInfo.GradientHorizontal(colours.Pink1.Lighten(lightenAmount), colours.Pink3.Lighten(lightenAmount)); + + default: + return Colour4.White; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); + scoringMode.BindValueChanged(s => + { + switch (s.NewValue) + { + case ScoringMode.Standardised: + rightContent.Width = 170; + break; + + case ScoringMode.Classic: + rightContent.Width = expanded_right_content_width; + break; + } + + updateModDisplay(); + }, true); + } + + private void updateModDisplay() + { + if (Score.Mods.Length > 0) + { + modsContainer.Padding = new MarginPadding { Top = 4f }; + modsContainer.ChildrenEnumerable = Score.Mods.AsOrdered().Select(mod => new ModIcon(mod) + { + Scale = new Vector2(0.3f), + // trim mod icon height down to its true height for alignment purposes. + Height = ModIcon.MOD_ICON_SIZE.Y * 3 / 4f, + }); + } + } + + private (CaseTransformableString, LocalisableString DisplayAccuracy)[] getStatistics(ScoreInfo model) => new[] + { + (BeatmapsetsStrings.ShowScoreboardHeadersCombo.ToUpper(), model.MaxCombo.ToString().Insert(model.MaxCombo.ToString().Length, "x")), + (BeatmapsetsStrings.ShowScoreboardHeadersAccuracy.ToUpper(), model.DisplayAccuracy), + }; + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + var lightenedGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0).Lighten(0.2f), backgroundColour.Lighten(0.2f)); + + foreground.FadeColour(IsHovered ? foregroundColour.Lighten(0.2f) : foregroundColour, transition_duration, Easing.OutQuint); + background.FadeColour(IsHovered ? backgroundColour.Lighten(0.2f) : backgroundColour, transition_duration, Easing.OutQuint); + totalScoreBackground.FadeColour(IsHovered ? lightenedGradient : totalScoreBackgroundGradient, transition_duration, Easing.OutQuint); + highlightGradient.FadeColour(getHighlightColour(Highlight, IsHovered ? 0.2f : 0), transition_duration, Easing.OutQuint); + + if (IsHovered && currentMode != DisplayMode.Full) + rankLabelOverlay.FadeIn(transition_duration, Easing.OutQuint); + else + rankLabelOverlay.FadeOut(transition_duration, Easing.OutQuint); + } + + private DisplayMode? currentMode; + + protected override void Update() + { + base.Update(); + + DisplayMode mode = getCurrentDisplayMode(); + + if (currentMode != mode) + updateDisplayMode(mode); + + centreContent.Padding = new MarginPadding + { + Left = rankLabelStandalone.DrawWidth, + Right = rightContent.DrawWidth, + }; + } + + private void updateDisplayMode(DisplayMode mode) + { + double duration = currentMode == null ? 0 : transition_duration; + if (mode >= DisplayMode.Full) + rankLabelStandalone.FadeIn(duration, Easing.OutQuint).ResizeWidthTo(rank_label_width, duration, Easing.OutQuint); + else + rankLabelStandalone.FadeOut(duration, Easing.OutQuint).ResizeWidthTo(0, duration, Easing.OutQuint); + + if (mode >= DisplayMode.Regular) + { + statisticsContainer.FadeIn(duration, Easing.OutQuint).MoveToX(0, duration, Easing.OutQuint); + statisticsContainer.Direction = FillDirection.Horizontal; + statisticsContainer.ScaleTo(1, duration, Easing.OutQuint); + } + else if (mode >= DisplayMode.Compact) + { + statisticsContainer.FadeIn(duration, Easing.OutQuint).MoveToX(0, duration, Easing.OutQuint); + statisticsContainer.Direction = FillDirection.Vertical; + statisticsContainer.ScaleTo(0.8f, duration, Easing.OutQuint); + } + else + statisticsContainer.FadeOut(duration, Easing.OutQuint).MoveToX(statisticsContainer.DrawWidth, duration, Easing.OutQuint); + + currentMode = mode; + } + + private DisplayMode getCurrentDisplayMode() + { + if (DrawWidth >= username_min_width + statistics_regular_min_width + expanded_right_content_width + rank_label_width) + return DisplayMode.Full; + + if (DrawWidth >= username_min_width + statistics_regular_min_width + expanded_right_content_width) + return DisplayMode.Regular; + + if (DrawWidth >= username_min_width + statistics_compact_min_width + expanded_right_content_width) + return DisplayMode.Compact; + + return DisplayMode.Minimal; + } + + ITooltip IHasCustomTooltip.GetCustomTooltip() => new LeaderboardScoreTooltip(colourProvider); + + ScoreInfo IHasCustomTooltip.TooltipContent => Score; + + MenuItem[] IHasContextMenu.ContextMenuItems + { + get + { + List items = new List(); + + // system mods should never be copied across regardless of anything. + var copyableMods = Score.Mods.Where(m => IsValidMod.Invoke(m) && m.Type != ModType.System).ToArray(); + + if (copyableMods.Length > 0) + items.Add(new OsuMenuItem(SongSelectStrings.UseTheseMods, MenuItemType.Highlighted, () => SelectedMods.Value = copyableMods)); + + if (Score.OnlineID > 0) + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}"))); + + if (Score.Files.Count <= 0) return items.ToArray(); + + items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(Score))); + items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); + + return items.ToArray(); + } + } + + private enum DisplayMode + { + Minimal, + Compact, + Regular, + Full + } + + private partial class DateLabel : DrawableDate + { + public DateLabel(DateTimeOffset date) + : base(date) + { + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold); + } + + protected override LocalisableString Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); + } + + private partial class ScoreComponentLabel : Container + { + private readonly LocalisableString name; + private readonly LocalisableString value; + private readonly bool perfect; + private readonly float minWidth; + + private FillFlowContainer content = null!; + public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); + + public ScoreComponentLabel(LocalisableString name, LocalisableString value, bool perfect, float minWidth) + { + this.name = name; + this.value = value; + this.perfect = perfect; + this.minWidth = minWidth; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + Child = content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Text = name, + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + }, + new OsuSpriteText + { + // We don't want the value setting the horizontal size, since it leads to wonky accuracy container length, + // since the accuracy is sometimes longer than its name. + BypassAutoSizeAxes = Axes.X, + Text = value, + Font = OsuFont.Style.Body, + Colour = perfect ? colours.Lime1 : Color4.White, + }, + Empty().With(d => d.Width = minWidth), + } + }; + } + } + + private partial class RankLabel : Container, IHasTooltip + { + private readonly bool darkText; + private readonly OsuSpriteText text; + + public RankLabel(int? rank, bool sheared, bool darkText) + { + this.darkText = darkText; + if (rank >= 1000) + TooltipText = $"#{rank:N0}"; + + Child = text = new OsuSpriteText + { + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Heading2, + Text = rank?.FormatRank().Insert(0, "#") ?? "-", + Shadow = !darkText, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + text.Colour = darkText ? colourProvider.Background3 : colourProvider.Content1; + } + + public LocalisableString TooltipText { get; } + } + + public enum HighlightType + { + Own, + Friend, + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs new file mode 100644 index 0000000000..8aa3a0516f --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs @@ -0,0 +1,528 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.PolygonExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.Leaderboards; +using osu.Game.Online.Placeholders; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapLeaderboardWedge : VisibilityContainer + { + public const float SPACING_BETWEEN_SCORES = 4; + + public IBindable Scope { get; } = new Bindable(); + + public IBindable Sorting { get; } = new Bindable(); + + public IBindable FilterBySelectedMods { get; } = new BindableBool(); + + [Resolved] + private LeaderboardManager leaderboardManager { get; set; } = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private Container placeholderContainer = null!; + private Placeholder? placeholder; + + private Container scoresContainer = null!; + + private OsuScrollContainer scoresScroll = null!; + private Container personalBestDisplay = null!; + + private Container personalBestScoreContainer = null!; + private OsuSpriteText personalBestText = null!; + private LoadingLayer loading = null!; + + private CancellationTokenSource? cancellationTokenSource; + + private readonly IBindable fetchedScores = new Bindable(); + + private const float personal_best_height = 112; + + // Blocking mouse down is required to avoid song select's background reveal logic happening while hovering scores. + // Our horizontal alignment doesn't really align with the rest of the sheared components (protrudes a touch to the right) which makes + // it complicated to handle this at a higher level. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => scoresScroll.ReceivePositionalInputAt(screenSpacePos); + + protected override bool OnMouseDown(MouseDownEvent e) => true; + + private Sample? swishSample; + + private readonly List scoreSfxDelegates = new List(); + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + RelativeSizeAxes = Axes.Both; + + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + scoresScroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Shear = OsuGame.SHEAR, + Child = scoresContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Top = 5, + // Left padding offsets the shear to create a visually appealing list display. + Left = 80f, + // Bottom padding ensures the last entry's full width is displayed + // (ie it is fully on screen after shear is considered). + Bottom = BeatmapLeaderboardScore.HEIGHT * 3 + }, + }, + }, + personalBestDisplay = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = personal_best_height, + Shear = OsuGame.SHEAR, + Margin = new MarginPadding + { + Left = -40f, + }, + CornerRadius = 10f, + Masking = true, + // push the personal best 1px down to hide masking issues + Y = 1f, + X = -100f, + Alpha = 0f, + Children = new Drawable[] + { + new WedgeBackground(), + // Required because wedge background blocks input from passing through + // to the main context menu container above. + new OsuContextMenuContainer + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Top = 5f, Bottom = 5f, Left = 70f, Right = 10f }, + Children = new Drawable[] + { + personalBestText = new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + personalBestScoreContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 20f }, + }, + } + }, + } + }, + }, + placeholderContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + loading = new LoadingLayer(), + } + }; + + swishSample = audio.Samples.Get(@"SongSelect/leaderboard-score"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Scope.BindValueChanged(_ => RefetchScores()); + Sorting.BindValueChanged(_ => RefetchScores()); + FilterBySelectedMods.BindValueChanged(_ => RefetchScores()); + beatmap.BindValueChanged(_ => RefetchScores()); + ruleset.BindValueChanged(_ => RefetchScores()); + mods.BindValueChanged(_ => refetchScoresFromMods()); + + RefetchScores(); + } + + protected override void PopIn() + { + this.FadeIn(300, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(300, Easing.OutQuint); + } + + private void refetchScoresFromMods() + { + if (FilterBySelectedMods.Value) + RefetchScores(); + } + + private bool initialFetchComplete; + + private ScheduledDelegate? refetchOperation; + + public void RefetchScores() + { + SetScores(Array.Empty()); + + if (beatmap.IsDefault) + { + SetState(LeaderboardState.NoneSelected); + return; + } + + SetState(LeaderboardState.Retrieving); + + refetchOperation?.Cancel(); + refetchOperation = Scheduler.AddDelayed(() => + { + var fetchBeatmapInfo = beatmap.Value.BeatmapInfo; + var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; + var fetchSorting = Scope.Value == BeatmapLeaderboardScope.Local ? Sorting.Value : LeaderboardSortMode.Score; + + // For now, we forcefully refresh to keep things simple. + // In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios + // (like returning from gameplay after setting a new score, returning to song select after main menu). + leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope.Value, FilterBySelectedMods.Value ? mods.Value.ToArray() : null, fetchSorting), + forceRefresh: true); + + if (!initialFetchComplete) + { + // only bind this after the first fetch to avoid reading stale scores. + fetchedScores.BindTo(leaderboardManager.Scores); + fetchedScores.BindValueChanged(_ => updateScores(), true); + initialFetchComplete = true; + } + }, initialFetchComplete ? 300 : 0); + } + + private void updateScores() + { + var scores = fetchedScores.Value; + + if (scores == null) return; + + // because leaderboard refetches are debounced, it is technically possible for the global leaderboard manager + // to contain scores for a different beatmap than the ones the wedge is currently on. + // in this case, ignore the incoming scores to avoid briefly flashing the wrong leaderboard. + if (leaderboardManager.CurrentCriteria?.Beatmap?.Equals(beatmap.Value.BeatmapInfo) != true) + return; + + if (scores.FailState != null) + SetState((LeaderboardState)scores.FailState); + else + SetScores(scores.TopScores, scores.UserScore, scores.TotalScores); + } + + protected void SetScores(IEnumerable scores, ScoreInfo? userScore = null, int? totalCount = null) + { + cancellationTokenSource?.Cancel(); + cancellationTokenSource = new CancellationTokenSource(); + + clearScores(); + SetState(LeaderboardState.Success); + + if (!scores.Any()) + { + SetState(LeaderboardState.NoScores); + return; + } + + LoadComponentsAsync(scores.Select((s, i) => + { + BeatmapLeaderboardScore.HighlightType? highlightType = null; + + if (s.OnlineID == userScore?.OnlineID) + highlightType = BeatmapLeaderboardScore.HighlightType.Own; + else if (api.LocalUserState.Friends.Any(r => r.TargetID == s.UserID) && Scope.Value != BeatmapLeaderboardScope.Friend) + highlightType = BeatmapLeaderboardScore.HighlightType.Friend; + + return new BeatmapLeaderboardScore(s) + { + Rank = i + 1, + Highlight = highlightType, + SelectedMods = { BindTarget = mods }, + Action = () => onLeaderboardScoreClicked(s), + }; + }), loadedScores => + { + int delay = 200; + int i = 0; + + foreach (var d in loadedScores) + { + d.Y = (BeatmapLeaderboardScore.HEIGHT + SPACING_BETWEEN_SCORES) * i; + + // This is a bit of a weird one. We're already in a sheared state and don't want top-level + // shear applied, but still need the `BeatmapLeaderboardScore` to be in "sheared" mode (see ctor). + d.Shear = Vector2.Zero; + + scoresContainer.Add(d); + + d.FadeOut() + .MoveToX(-20f) + .Delay(delay) + .FadeIn(300, Easing.OutQuint) + .MoveToX(0f, 300, Easing.OutQuint); + + bool visible = d.ScreenSpaceDrawQuad.TopLeft.Y < d.Parent!.ChildMaskingBounds.BottomLeft.Y; + + if (visible) + { + var del = Scheduler.AddDelayed(() => + { + var chan = swishSample?.GetChannel(); + if (chan == null) return; + + chan.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH / 2; + chan.Frequency.Value = 0.98f + RNG.NextDouble(0.04f); + chan.Play(); + }, delay); + + scoreSfxDelegates.Add(del); + } + + delay += 30; + i++; + } + }, cancellation: cancellationTokenSource.Token); + + if (userScore != null) + { + personalBestDisplay.MoveToX(0, 600, Easing.OutQuint); + personalBestDisplay.FadeIn(600, Easing.OutQuint); + personalBestScoreContainer.Child = new BeatmapLeaderboardScore(userScore) + { + Highlight = BeatmapLeaderboardScore.HighlightType.Own, + Rank = userScore.Position, + SelectedMods = { BindTarget = mods }, + Action = () => onLeaderboardScoreClicked(userScore), + }; + + scoresScroll.TransformTo(nameof(scoresScroll.Padding), new MarginPadding { Bottom = personal_best_height }, 300, Easing.OutQuint); + + if (totalCount != null && userScore.Position != null) + personalBestText.Text = $"Personal Best (#{userScore.Position:N0} of {totalCount.Value:N0})"; + else + personalBestText.Text = "Personal Best"; + } + } + + private void clearScores() + { + float delay = 0; + + foreach (var d in scoresContainer) + { + // Avoid applying animations a second time to drawables which are already fading out. + if (d.LifetimeEnd != double.MaxValue) + continue; + + d.Delay(delay) + .MoveToX(-10f, 120, Easing.Out) + .FadeOut(120, Easing.Out) + .Expire(); + + // If the user is scrolled down in the list, start delaying only from the current visible range to + // avoid the perceived transition from taking longer than expected. + if (d.ScreenSpaceDrawQuad.Intersects(scoresScroll.ScreenSpaceDrawQuad)) + delay += 20; + } + + personalBestDisplay.MoveToX(-100, 300, Easing.OutQuint); + personalBestDisplay.FadeOut(300, Easing.OutQuint); + scoresScroll.TransformTo(nameof(scoresScroll.Padding), new MarginPadding(), 300, Easing.OutQuint); + + scoreSfxDelegates.ForEach(d => d.Cancel()); + scoreSfxDelegates.Clear(); + } + + private void onLeaderboardScoreClicked(ScoreInfo score) => songSelect?.PresentScore(score); + + private LeaderboardState displayedState; + + protected void SetState(LeaderboardState state) + { + if (state == displayedState) + return; + + if (state == LeaderboardState.Retrieving) + loading.Show(); + else + loading.Hide(); + + displayedState = state; + + placeholder?.FadeOut(150, Easing.OutQuint).Expire(); + placeholder = getPlaceholderFor(state); + + if (placeholder == null) + return; + + clearScores(); + + placeholderContainer.Child = placeholder; + + placeholder.ScaleTo(0.8f).Then().ScaleTo(1, 900, Easing.OutQuint); + placeholder.FadeInFromZero(300, Easing.OutQuint); + } + + #region Fade handling + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + const int height = BeatmapLeaderboardScore.HEIGHT; + + float fadeBottom = (float)(scoresScroll.Current + scoresScroll.DrawHeight); + float fadeTop = (float)(scoresScroll.Current); + + fadeTop += (float)Math.Min(height, Math.Log10(Math.Max(fadeTop, 0) + 1) * height); + + foreach (var c in scoresContainer) + { + float topY = c.ToSpaceOfOtherDrawable(Vector2.Zero, scoresContainer).Y; + float bottomY = topY + height; + + bool requireBottomFade = bottomY >= fadeBottom; + bool requireTopFade = topY < fadeTop; + + if (!requireBottomFade && !requireTopFade) + { + c.Colour = Color4.White; + continue; + } + + if (topY > fadeBottom + height || bottomY < fadeTop - height) + { + c.Colour = Color4.Transparent; + continue; + } + + if (requireBottomFade) + { + c.Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / height, 1)), + Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / height, 1))); + } + else + { + Debug.Assert(requireTopFade); + + c.Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / height, 1)), + Color4.White.Opacity(Math.Min(1 - (fadeTop - bottomY) / height, 1))); + } + } + } + + #endregion + + private Placeholder? getPlaceholderFor(LeaderboardState state) + { + switch (state) + { + case LeaderboardState.NetworkFailure: + return new ClickablePlaceholder(LeaderboardStrings.CouldntFetchScores, FontAwesome.Solid.Sync) + { + Action = RefetchScores + }; + + case LeaderboardState.NoneSelected: + return new MessagePlaceholder(LeaderboardStrings.PleaseSelectABeatmap); + + case LeaderboardState.RulesetUnavailable: + return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisRuleset); + + case LeaderboardState.BeatmapUnavailable: + return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisBeatmap); + + case LeaderboardState.NoScores: + return new MessagePlaceholder(LeaderboardStrings.NoRecordsYet); + + case LeaderboardState.NotLoggedIn: + return new LoginPlaceholder(LeaderboardStrings.PleaseSignInToViewOnlineLeaderboards); + + case LeaderboardState.NotSupporter: + return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard); + + case LeaderboardState.NoTeam: + return new MessagePlaceholder(LeaderboardStrings.NoTeam); + + case LeaderboardState.Retrieving: + return null; + + case LeaderboardState.Success: + return null; + + default: + throw new ArgumentOutOfRangeException(nameof(state)); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.FailRetryDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.FailRetryDisplay.cs new file mode 100644 index 0000000000..9ee61b7c5c --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.FailRetryDisplay.cs @@ -0,0 +1,237 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class FailRetryDisplay : CompositeDrawable + { + private readonly GraphDrawable retriesGraph; + private readonly GraphDrawable failsGraph; + + public APIFailTimes Data + { + set + { + int[] retries = value.Retries ?? Array.Empty(); + int[] fails = value.Fails ?? Array.Empty(); + int[] total = retries.Zip(fails, (r, f) => r + f).ToArray(); + + int maximum = total.DefaultIfEmpty(0).Max(); + + retriesGraph.Data = total.Select(r => maximum == 0 ? 0 : (float)r / maximum).ToArray(); + failsGraph.Data = fails.Select(r => maximum == 0 ? 0 : (float)r / maximum).ToArray(); + } + } + + public FailRetryDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowInfoPointsOfFailure, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Margin = new MarginPadding { Bottom = 4f }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 65f, + Children = new[] + { + retriesGraph = new GraphDrawable { RelativeSizeAxes = Axes.Both, Y = -1f }, + failsGraph = new GraphDrawable { RelativeSizeAxes = Axes.Both }, + }, + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + retriesGraph.Colour = colours.Orange1; + failsGraph.Colour = colours.DarkOrange2; + } + + private partial class GraphDrawable : Drawable + { + private readonly float[] displayedData = new float[100]; + + private float[] data = new float[100]; + + public float[] Data + { + get => data; + set + { + data = value; + Invalidate(Invalidation.DrawNode); + } + } + + private IShader shader = null!; + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "FastCircle"); + } + + protected override void Update() + { + base.Update(); + + bool changed = false; + + for (int i = 0; i < displayedData.Length; i++) + { + float before = displayedData[i]; + float value = data.ElementAtOrDefault(i); + displayedData[i] = (float)Interpolation.DampContinuously(displayedData[i], value, 40, Time.Elapsed); + changed |= displayedData[i] != before; + } + + if (changed) + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new GraphDrawNode(this); + + // todo: consider integrating this with BarGraph + // this is different from BarGraph since this displays each bar with corner radii applied. + private class GraphDrawNode : DrawNode + { + private readonly GraphDrawable source; + + private Vector2 drawSize; + private float[] displayedData = null!; + private IShader shader = null!; + private IVertexBatch? quadBatch; + + public GraphDrawNode(GraphDrawable source) + : base(source) + { + this.source = source; + } + + public override void ApplyState() + { + base.ApplyState(); + + drawSize = source.DrawSize; + displayedData = source.displayedData; + shader = source.shader; + } + + protected override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + const float spacing_constant = 1.5f; + + float position = 0; + float barWidth = drawSize.X / displayedData.Length / spacing_constant; + + float totalSpacing = drawSize.X - barWidth * displayedData.Length; + float spacing = totalSpacing / (displayedData.Length - 1); + + quadBatch ??= renderer.CreateQuadBatch(displayedData.Length * 4, 1); + shader.Bind(); + + for (int i = 0; i < displayedData.Length; i++) + { + float barHeight = MathF.Max(drawSize.Y * displayedData[i], barWidth); + + drawBar(renderer, position, barWidth, barHeight); + + position += barWidth + spacing; + } + + shader.Unbind(); + } + + private void drawBar(IRenderer renderer, float position, float width, float height) + { + // Since bars have corner radius, to avoid masking usage and draw all bars in a single draw call + // we are using FastCircle implementation. + // Not using FastCircle directly to minimize drawable count. + + RectangleF drawRectangle = new RectangleF(new Vector2(position, drawSize.Y - height), new Vector2(width, height)); + Vector4 textureRectangle = new Vector4(0, 0, drawRectangle.Width, drawRectangle.Height); + Quad screenSpaceDrawQuad = Quad.FromRectangle(drawRectangle) * DrawInfo.Matrix; + + var blend = new Vector2(Math.Min(drawRectangle.Width, drawRectangle.Height) / Math.Min(screenSpaceDrawQuad.Width, screenSpaceDrawQuad.Height)); + + quadBatch?.AddAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.BottomLeft, + TexturePosition = new Vector2(0, drawRectangle.Height), + TextureRect = textureRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.BottomLeft.SRGB, + }); + quadBatch?.AddAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.BottomRight, + TexturePosition = new Vector2(drawRectangle.Width, drawRectangle.Height), + TextureRect = textureRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.BottomRight.SRGB, + }); + quadBatch?.AddAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.TopRight, + TexturePosition = new Vector2(drawRectangle.Width, 0), + TextureRect = textureRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.TopRight.SRGB, + }); + quadBatch?.AddAction(new TexturedVertex2D(renderer) + { + Position = screenSpaceDrawQuad.TopLeft, + TexturePosition = Vector2.Zero, + TextureRect = textureRectangle, + BlendRange = blend, + Colour = DrawColourInfo.Colour.TopLeft.SRGB, + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + quadBatch?.Dispose(); + } + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.MetadataDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.MetadataDisplay.cs new file mode 100644 index 0000000000..1c3cf8f8eb --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.MetadataDisplay.cs @@ -0,0 +1,180 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class MetadataDisplay : FillFlowContainer + { + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText contentText; + private readonly OsuSpriteText contentLinkText; + private readonly OsuHoverContainer contentLink; + private readonly DrawableDate contentDate; + private readonly TagsLine contentTags; + private readonly LoadingSpinner contentLoading; + + private (LocalisableString value, Action? linkAction)? data; + + public (LocalisableString value, Action? linkAction)? Data + { + get => data; + set + { + data = value; + + if (value?.linkAction != null) + setLink(value.Value.value, value.Value.linkAction); + else if (value.HasValue) + setText(value.Value.value); + else + setLoading(); + } + } + + public DateTimeOffset? Date + { + set + { + if (value != null) + setDate(value.Value); + else + setText("-"); + } + } + + public (string[] tags, Action searchAction)? Tags + { + set + { + if (value != null) + setTags(value.Value.tags, value.Value.searchAction); + else + setLoading(); + } + } + + public MetadataDisplay(LocalisableString label) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Padding = new MarginPadding { Right = 10 }; + + InternalChildren = new Drawable[] + { + labelText = new OsuSpriteText + { + Text = label, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Caption1.Size, + Children = new Drawable[] + { + contentText = new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Font = OsuFont.Style.Caption1, + }, + contentLink = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Child = contentLinkText = new TruncatingSpriteText + { + Font = OsuFont.Style.Caption1, + }, + }, + contentDate = new DrawableDate(default, OsuFont.Style.Caption1.Size, false), + contentTags = new TagsLine(), + contentLoading = new LoadingSpinner + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Size = new Vector2(10), + Margin = new MarginPadding { Top = 3f }, + State = { Value = Visibility.Visible }, + } + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + labelText.Colour = colourProvider.Content1; + contentText.Colour = colourProvider.Content2; + contentLink.IdleColour = colourProvider.Light2; + } + + protected override void Update() + { + base.Update(); + contentLinkText.MaxWidth = ChildSize.X; + } + + private void clear() + { + contentText.Text = string.Empty; + contentLinkText.Text = string.Empty; + contentDate.Hide(); + contentTags.Tags = Array.Empty(); + contentLoading.Hide(); + } + + private void setText(LocalisableString text) + { + clear(); + + contentText.Text = text; + } + + private void setLink(LocalisableString text, Action action) => Schedule(() => + { + clear(); + + contentLinkText.Text = text; + contentLink.Action = action; + }); + + private void setDate(DateTimeOffset date) + { + clear(); + + contentDate.Show(); + contentDate.Date = date; + } + + private void setTags(string[] tags, Action searchAction) + { + clear(); + + contentTags.PerformSearch = searchAction; + contentTags.Tags = tags; + } + + private void setLoading() + { + clear(); + + contentLoading.Show(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.RatingSpreadDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.RatingSpreadDisplay.cs new file mode 100644 index 0000000000..ee938ecdd9 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.RatingSpreadDisplay.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class RatingSpreadDisplay : CompositeDrawable + { + private const float min_height = 4f; + private const float max_height = 32f; + + private const int rating_range = 10; + + private readonly GraphBar[] graph; + + public int[] Data + { + set + { + if (!value.Any()) + { + foreach (var bar in graph) + bar.ResizeHeightTo(min_height, 300, Easing.OutQuint); + } + else + { + var usableRange = value.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. + int maxRating = usableRange.Max(); + + for (int i = 0; i < graph.Length; i++) + graph[i].ResizeHeightTo(min_height + (max_height - min_height) * (maxRating == 0 ? 0 : usableRange.ElementAt(i) / (float)maxRating), 300, Easing.OutQuint); + } + } + } + + public RatingSpreadDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + graph = Enumerable.Range(0, rating_range).Select(_ => new GraphBar()).ToArray(); + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 1f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowStatsRatingSpread, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, max_height) }, + ColumnDimensions = graph.SkipLast(1).Select(_ => new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 1f), + }).SelectMany(d => d).Append(new Dimension()).ToArray(), + Content = new[] + { + graph.SkipLast(1).Select(g => new[] + { + g, + Empty() + }).SelectMany(g => g).Append(graph[^1]).ToArray() + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + for (int i = 0; i < 10; i++) + { + var left = Interpolation.ValueAt(i, colours.Blue4, colours.Blue0, 0, 10); + var right = Interpolation.ValueAt(i + 1, colours.Blue4, colours.Blue0, 0, 10); + graph[i].Colour = ColourInfo.GradientHorizontal(left, right); + } + } + + private partial class GraphBar : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + RelativeSizeAxes = Axes.X; + CornerRadius = 2f; + Masking = true; + + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.SuccessRateDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.SuccessRateDisplay.cs new file mode 100644 index 0000000000..6118547274 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.SuccessRateDisplay.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class SuccessRateDisplay : CompositeDrawable, IHasTooltip + { + private readonly OsuSpriteText valueText; + private readonly Circle backgroundBar; + private readonly Circle valueBar; + + private (int passes, int plays) data; + + public (int passes, int plays) Data + { + get => data; + set + { + data = value; + + float ratio = value.plays == 0 ? 0 : (float)value.passes / value.plays; + + valueText.Text = ratio.ToLocalisableString(@"0.##%"); + valueText.MoveToX(Math.Clamp(ratio, 0.05f, 0.95f), 300, Easing.OutQuint); + valueBar.ResizeWidthTo(ratio, 300, Easing.OutQuint); + } + } + + public LocalisableString TooltipText => $"{data.passes:N0} / {data.plays:N0}"; + + public SuccessRateDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowInfoSuccessRate, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10f }, + Child = valueText = new OsuSpriteText + { + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + Font = OsuFont.Style.Caption1, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + backgroundBar = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 4f, + }, + valueBar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 4f, + }, + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + backgroundBar.Colour = colourProvider.Background6; + valueBar.Colour = colours.Lime1; + valueText.Colour = colourProvider.Content2; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.TagsLine.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.TagsLine.cs new file mode 100644 index 0000000000..e48b4f20da --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.TagsLine.cs @@ -0,0 +1,225 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +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.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Layout; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class TagsLine : FillFlowContainer + { + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + private string[] tags = Array.Empty(); + + private TagsOverflowButton? overflowButton; + + public string[] Tags + { + get => tags; + set + { + tags = value; + updateTags(); + } + } + + public Action? PerformSearch { get; set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public TagsLine() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(4, 0); + + AddLayout(drawSizeLayout); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!drawSizeLayout.IsValid) + { + updateLayout(); + drawSizeLayout.Validate(); + } + } + + private void updateLayout() + { + if (tags.Length == 0) + return; + + Debug.Assert(overflowButton != null); + + float limit = DrawWidth - overflowButton.DrawWidth - 5; + bool showOverflow = false; + + foreach (var text in Children) + { + if (text.X + text.DrawWidth < limit) + text.Show(); + else + { + showOverflow = true; + text.AlwaysPresent = false; + text.Hide(); + } + } + + if (showOverflow) + overflowButton.Show(); + else + overflowButton.Hide(); + } + + private void updateTags() + { + ChildrenEnumerable = tags.Select(t => new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Action = () => PerformSearch?.Invoke(t), + IdleColour = colourProvider.Light2, + AlwaysPresent = true, + Alpha = 0f, + Child = new OsuSpriteText + { + Text = t, + Font = OsuFont.Style.Caption1, + }, + }); + + Add(overflowButton = new TagsOverflowButton(tags) + { + Alpha = 0f, + PerformSearch = s => PerformSearch?.Invoke(s), + }); + + drawSizeLayout.Invalidate(); + } + + private partial class TagsOverflowButton : CompositeDrawable, IHasPopover, IHasLineBaseHeight + { + private readonly string[] tags; + + private Box box = null!; + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public float LineBaseHeight => text.LineBaseHeight; + + public Action? PerformSearch { get; init; } + + public TagsOverflowButton(string[] tags) + { + this.tags = tags; + } + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(OsuFont.Style.Caption1.Size); + CornerRadius = 1.5f; + Masking = true; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = colourProvider.Light1, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Y = -2, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "...", + Colour = colourProvider.Background4, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + }, + new HoverClickSounds(), + }; + } + + protected override bool OnHover(HoverEvent e) + { + box.FadeColour(colourProvider.Content2, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + box.FadeColour(colourProvider.Light1, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + box.FlashColour(colourProvider.Content1, 300, Easing.OutQuint); + this.ShowPopover(); + return true; + } + + public Popover GetPopover() => new TagsOverflowPopover(tags, PerformSearch); + } + + public partial class TagsOverflowPopover : OsuPopover + { + private readonly string[] tags; + private readonly Action? performSearch; + + public TagsOverflowPopover(string[] tags, Action? performSearchAction) + { + this.tags = tags; + performSearch = performSearchAction; + } + + [BackgroundDependencyLoader] + private void load() + { + LinkFlowContainer textFlow; + + Child = textFlow = new LinkFlowContainer(t => t.Font = OsuFont.Style.Caption1) + { + Width = 200, + AutoSizeAxes = Axes.Y, + }; + + foreach (string tag in tags) + { + textFlow.AddLink(tag, () => performSearch?.Invoke(tag)); + textFlow.AddText(" "); + } + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.UserRatingDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.UserRatingDisplay.cs new file mode 100644 index 0000000000..2f38079577 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.UserRatingDisplay.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.Linq; +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.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class UserRatingDisplay : CompositeDrawable + { + private readonly OsuSpriteText negativeText; + private readonly OsuSpriteText positiveText; + private readonly Circle backgroundBar; + private readonly Circle positiveBar; + + public int[] Data + { + set + { + const int rating_range = 10; + + if (!value.Any()) + { + negativeText.Text = 0.ToLocalisableString(@"N0"); + positiveText.Text = 0.ToLocalisableString(@"N0"); + positiveBar.ResizeWidthTo(0, 300, Easing.OutQuint); + } + else + { + var usableRange = value.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. + + int positiveCount = usableRange.Skip(rating_range / 2).Sum(); + int totalCount = usableRange.Sum(); + + negativeText.Text = (totalCount - positiveCount).ToLocalisableString(@"N0"); + positiveText.Text = positiveCount.ToLocalisableString(@"N0"); + positiveBar.ResizeWidthTo(totalCount == 0 ? 0 : (float)positiveCount / totalCount, 300, Easing.OutQuint); + } + } + } + + public UserRatingDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowStatsUserRating, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10f }, + Children = new[] + { + negativeText = new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Style.Caption1, + }, + positiveText = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.Style.Caption1, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + backgroundBar = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 4f, + }, + positiveBar = new Circle + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 4f, + }, + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + backgroundBar.Colour = colours.DarkOrange2; + positiveBar.Colour = colours.Lime1; + negativeText.Colour = colourProvider.Content2; + positiveText.Colour = colourProvider.Content2; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs new file mode 100644 index 0000000000..d516f4b846 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -0,0 +1,449 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +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.Utils; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge : VisibilityContainer + { + private MetadataDisplay creator = null!; + private MetadataDisplay source = null!; + private MetadataDisplay genre = null!; + private MetadataDisplay language = null!; + private MetadataDisplay userTags = null!; + private MetadataDisplay mapperTags = null!; + private MetadataDisplay submitted = null!; + private MetadataDisplay ranked = null!; + + private Drawable ratingsWedge = null!; + private SuccessRateDisplay successRateDisplay = null!; + private UserRatingDisplay userRatingDisplay = null!; + private RatingSpreadDisplay ratingSpreadDisplay = null!; + + private Drawable failRetryWedge = null!; + private FailRetryDisplay failRetryDisplay = null!; + + public bool RatingsVisible => ratingsWedge.Alpha > 0; + public bool FailRetryVisible => failRetryWedge.Alpha > 0; + + protected override bool StartHidden => true; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable onlineLookupResult { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IBindable apiState = null!; + + [Resolved] + private ILinkHandler? linkHandler { get; set; } + + [Resolved] + private ISongSelect? songSelect { get; set; } + + private Sample? wedgeAppearSample; + private Sample? wedgeHideSample; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Top = 4f }; + + Width = 0.9f; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Shear = OsuGame.SHEAR, + Children = new[] + { + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 35, Vertical = 16 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + AutoSizeDuration = (float)transition_duration / 3, + AutoSizeEasing = Easing.OutQuint, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(), + new Dimension(), + }, + Content = new[] + { + new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + creator = new MetadataDisplay(EditorSetupStrings.Creator), + genre = new MetadataDisplay(BeatmapsetsStrings.ShowInfoGenre), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + source = new MetadataDisplay(BeatmapsetsStrings.ShowInfoSource), + language = new MetadataDisplay(BeatmapsetsStrings.ShowInfoLanguage), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + submitted = new MetadataDisplay(SongSelectStrings.Submitted), + ranked = new MetadataDisplay(SongSelectStrings.Ranked), + }, + }, + }, + }, + }, + userTags = new MetadataDisplay(BeatmapsetsStrings.ShowInfoUserTags) + { + Alpha = 0, + }, + mapperTags = new MetadataDisplay(BeatmapsetsStrings.ShowInfoMapperTags), + }, + }, + }, + }, + }, + }), + new ShearAligningWrapper(ratingsWedge = new Container + { + Alpha = 0f, + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Content = new[] + { + new[] + { + successRateDisplay = new SuccessRateDisplay(), + Empty(), + userRatingDisplay = new UserRatingDisplay(), + Empty(), + ratingSpreadDisplay = new RatingSpreadDisplay(), + }, + }, + }, + } + }), + new ShearAligningWrapper(failRetryWedge = new Container + { + Alpha = 0f, + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Child = failRetryDisplay = new FailRetryDisplay(), + }, + }, + }), + } + }; + + wedgeAppearSample = audio.Samples.Get(@"SongSelect/metadata-wedge-pop-in"); + wedgeHideSample = audio.Samples.Get(@"SongSelect/metadata-wedge-pop-out"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + beatmap.BindValueChanged(_ => updateDisplay()); + onlineLookupResult.BindValueChanged(_ => updateDisplay()); + + apiState = api.State.GetBoundCopy(); + apiState.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay), true); + } + + private const double transition_duration = 300; + + protected override void PopIn() + { + this.FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + + updateSubWedgeVisibility(); + } + + protected override void PopOut() + { + this.FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-100, transition_duration, Easing.OutQuint); + + updateSubWedgeVisibility(); + } + + private void updateSubWedgeVisibility() + { + // We could consider hiding individual wedges based on zero data in the future. + // Needs some experimentation on what looks good. + + var beatmapInfo = beatmap.Value.BeatmapInfo; + var currentOnlineBeatmap = onlineLookupResult.Value?.Result?.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID); + + if (State.Value == Visibility.Visible && currentOnlineBeatmap != null) + { + // play show sounds only if the wedges were previously hidden + if (ratingsWedge.Alpha < 1) + playWedgeAppearSound(); + + ratingsWedge.FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + + failRetryWedge.Delay(100) + .FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + } + else + { + // play hide sounds only if the wedges were previously visible + if (ratingsWedge.Alpha > 0) + playWedgeHideSound(); + + failRetryWedge.FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-50, transition_duration, Easing.OutQuint); + + ratingsWedge.Delay(100) + .FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-50, transition_duration, Easing.OutQuint); + } + } + + private void playWedgeAppearSound() + { + var wedgeAppearChannel1 = wedgeAppearSample?.GetChannel(); + if (wedgeAppearChannel1 == null) + return; + + wedgeAppearChannel1.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH / 2; + wedgeAppearChannel1.Frequency.Value = 0.98f + RNG.NextDouble(0.04f); + wedgeAppearChannel1.Play(); + + Scheduler.AddDelayed(() => + { + var wedgeAppearChannel2 = wedgeAppearSample?.GetChannel(); + if (wedgeAppearChannel2 == null) + return; + + wedgeAppearChannel2.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH / 2; + wedgeAppearChannel2.Frequency.Value = 0.90f + RNG.NextDouble(0.05f); + wedgeAppearChannel2.Play(); + }, 100); + } + + private void playWedgeHideSound() + { + var wedgeHideChannel = wedgeHideSample?.GetChannel(); + if (wedgeHideChannel == null) + return; + + wedgeHideChannel.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH / 2; + wedgeHideChannel.Play(); + } + + private void updateDisplay() + { + var metadata = beatmap.Value.Metadata; + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + creator.Data = (metadata.Author.Username, () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, metadata.Author))); + + if (!string.IsNullOrEmpty(metadata.Source)) + source.Data = (metadata.Source, () => songSelect?.Search(metadata.Source)); + else + source.Data = ("-", null); + + if (!string.IsNullOrEmpty(metadata.Tags)) + mapperTags.Tags = (metadata.Tags.Split(' '), t => songSelect?.Search(t)); + else + mapperTags.Tags = (Array.Empty(), _ => { }); + + submitted.Date = beatmapSetInfo.DateSubmitted; + ranked.Date = beatmapSetInfo.DateRanked; + + updateOnlineDisplay(); + } + + private void updateOnlineDisplay() + { + if (onlineLookupResult.Value?.Status != SongSelect.BeatmapSetLookupStatus.Completed) + { + genre.Data = null; + language.Data = null; + userTags.Tags = null; + return; + } + + if (onlineLookupResult.Value.Result == null) + { + genre.Data = ("-", null); + language.Data = ("-", null); + } + else + { + var beatmapInfo = beatmap.Value.BeatmapInfo; + + var onlineBeatmapSet = onlineLookupResult.Value.Result; + var onlineBeatmap = onlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID); + + genre.Data = (onlineBeatmapSet.Genre.Name, () => songSelect?.Search(onlineBeatmapSet.Genre.Name)); + language.Data = (onlineBeatmapSet.Language.Name, () => songSelect?.Search(onlineBeatmapSet.Language.Name)); + + if (onlineBeatmap != null) + { + userRatingDisplay.Data = onlineBeatmapSet.Ratings; + ratingSpreadDisplay.Data = onlineBeatmapSet.Ratings; + successRateDisplay.Data = (onlineBeatmap.PassCount, onlineBeatmap.PlayCount); + failRetryDisplay.Data = onlineBeatmap.FailTimes ?? new APIFailTimes(); + } + } + + updateUserTags(); + updateSubWedgeVisibility(); + } + + private CancellationTokenSource? userTagsCancellationSource; + + private void updateUserTags() + { + userTagsCancellationSource?.Cancel(); + userTagsCancellationSource = new CancellationTokenSource(); + + var token = userTagsCancellationSource.Token; + + realm.RunAsync(r => + { + // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags + var refetchedBeatmap = r.Find(beatmap.Value.BeatmapInfo.ID); + return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? []; + }, token).ContinueWith(t => + { + string[] tags = t.GetResultSafely(); + + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + if (tags.Length == 0) + { + userTags.FadeOut(transition_duration, Easing.OutQuint); + return; + } + + userTags.FadeIn(transition_duration, Easing.OutQuint); + userTags.Tags = (tags, tag => songSelect?.Search($@"tag=""{tag}""!")); + }); + }, token); + } + + protected override void Dispose(bool isDisposing) + { + userTagsCancellationSource?.Cancel(); + userTagsCancellationSource = null; + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs new file mode 100644 index 0000000000..55ed488d87 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs @@ -0,0 +1,326 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class DifficultyDisplay : CompositeDrawable + { + private const float border_weight = 2; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + private ModSettingChangeTracker? settingChangeTracker; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private StarRatingDisplay starRatingDisplay = null!; + private FillFlowContainer nameLine = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText mappedByText = null!; + private OsuHoverContainer mapperLink = null!; + private OsuSpriteText mapperText = null!; + + private GridContainer ratingAndNameContainer = null!; + private DifficultyStatisticsDisplay countStatisticsDisplay = null!; + private DifficultyStatisticsDisplay difficultyStatisticsDisplay = null!; + + private CancellationTokenSource? cancellationSource; + + public DifficultyDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 10; + Shear = OsuGame.SHEAR; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new ShearAligningWrapper(ratingAndNameContainer = new GridContainer + { + Shear = -OsuGame.SHEAR, + AlwaysPresent = true, + RelativeSizeAxes = Axes.X, + Height = 20, + Margin = new MarginPadding { Vertical = 5f }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 6), + new Dimension(), + }, + Content = new[] + { + new[] + { + starRatingDisplay = new StarRatingDisplay(default, animated: true) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + Empty(), + nameLine = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Bottom = 2f }, + Children = new Drawable[] + { + difficultyText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + mappedByText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = " mapped by ", + Font = OsuFont.Style.Body, + }, + mapperLink = new MapperLinkContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Child = mapperText = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + }, + }, + }, + } + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = 53, + Padding = new MarginPadding { Bottom = border_weight, Right = border_weight }, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 10 - border_weight, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.8f), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 20f, Vertical = 7.5f }, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + countStatisticsDisplay = new DifficultyStatisticsDisplay + { + RelativeSizeAxes = Axes.X, + }, + Empty(), + difficultyStatisticsDisplay = new DifficultyStatisticsDisplay(autoSize: true), + } + }, + } + }, + } + }), + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => updateDisplay()); + ruleset.BindValueChanged(_ => updateDisplay()); + + mods.BindValueChanged(m => + { + settingChangeTracker?.Dispose(); + + updateDifficultyStatistics(); + + if (m.NewValue.Any()) + { + settingChangeTracker = new ModSettingChangeTracker(m.NewValue); + settingChangeTracker.SettingChanged += _ => updateDifficultyStatistics(); + } + }, true); + + updateDisplay(); + } + + [Resolved] + private ILinkHandler? linkHandler { get; set; } + + private void updateDisplay() + { + cancellationSource?.Cancel(); + cancellationSource = new CancellationTokenSource(); + + if (beatmap.IsDefault) + { + ratingAndNameContainer.FadeOut(300, Easing.OutQuint); + countStatisticsDisplay.FadeOut(300, Easing.OutQuint); + } + else + { + ratingAndNameContainer.FadeIn(300, Easing.OutQuint); + difficultyText.Text = beatmap.Value.BeatmapInfo.DifficultyName; + mapperLink.Action = () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, beatmap.Value.Metadata.Author)); + mapperText.Text = beatmap.Value.Metadata.Author.Username; + } + + starRatingDisplay.Current = (Bindable)difficultyCache.GetBindableDifficulty(beatmap.Value.BeatmapInfo, cancellationSource.Token, SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE); + + updateCountStatistics(cancellationSource.Token); + updateDifficultyStatistics(); + } + + private void updateCountStatistics(CancellationToken cancellationToken) + { + if (beatmap.IsDefault) + { + countStatisticsDisplay.FadeOut(300, Easing.OutQuint); + return; + } + + Task.Run(() => + { + // This can take time as it is a synchronous task. + // TODO: We're calling `GetPlayableBeatmap` multiple times every map load at song select. + var playableBeatmap = beatmap.Value.GetPlayableBeatmap(ruleset.Value); + var statistics = playableBeatmap.GetStatistics() + .Select(s => new StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content)) + .ToList(); + + Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + countStatisticsDisplay.FadeIn(200, Easing.OutQuint); + countStatisticsDisplay.Statistics = statistics; + }); + }, cancellationToken); + } + + private void updateDifficultyStatistics() => Scheduler.AddOnce(() => + { + if (beatmap.IsDefault || ruleset.Value == null) + { + difficultyStatisticsDisplay.Statistics = Array.Empty(); + return; + } + + Ruleset rulesetInstance = ruleset.Value.CreateInstance(); + + var displayAttributes = rulesetInstance.GetBeatmapAttributesForDisplay(beatmap.Value.BeatmapInfo, mods.Value).ToList(); + difficultyStatisticsDisplay.Statistics = displayAttributes.Select(a => new StatisticDifficulty.Data(a)).ToList(); + }); + + protected override void Update() + { + base.Update(); + + difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); + + // Use difficulty colour until it gets too dark to be visible against dark backgrounds. + Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour; + + difficultyText.Colour = col; + mappedByText.Colour = col; + countStatisticsDisplay.AccentColour = col; + difficultyStatisticsDisplay.AccentColour = col; + } + + private partial class MapperLinkContainer : OsuHoverContainer + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + TooltipText = ContextMenuStrings.ViewProfile; + IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs new file mode 100644 index 0000000000..595959cfce --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyStatisticsDisplay.cs @@ -0,0 +1,220 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +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.Layout; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class DifficultyStatisticsDisplay : CompositeDrawable + { + private readonly bool autoSize; + private readonly FillFlowContainer statisticsFlow; + private readonly GridContainer tinyStatisticsGrid; + + private IReadOnlyList statistics = Array.Empty(); + + public IReadOnlyList Statistics + { + get => statistics; + set + { + statistics = value; + + if (IsLoaded) + { + updateStatistics(); + updateTinyStatistics(); + } + } + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (accentColour == value) + return; + + accentColour = value; + + foreach (var statistic in statisticsFlow) + statistic.AccentColour = value; + } + } + + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public DifficultyStatisticsDisplay(bool autoSize = false) + { + this.autoSize = autoSize; + + if (autoSize) + AutoSizeAxes = Axes.Both; + else + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + statisticsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(8f, 0f), + Direction = FillDirection.Horizontal, + AlwaysPresent = true, + }, + tinyStatisticsGrid = new GridContainer + { + Alpha = 0f, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 8), + new Dimension(GridSizeMode.AutoSize), + } + }, + }; + + AddLayout(drawSizeLayout); + } + + [Resolved] + private LocalisationManager localisations { get; set; } = null!; + + private IBindable? localisationParameters; + + protected override void LoadComplete() + { + base.LoadComplete(); + + localisationParameters = localisations.CurrentParameters.GetBoundCopy(); + localisationParameters.BindValueChanged(_ => updateStatisticsSizing()); + + updateStatistics(); + updateTinyStatistics(); + } + + protected override void Update() + { + base.Update(); + + if (!drawSizeLayout.IsValid) + { + updateLayout(); + drawSizeLayout.Validate(); + } + } + + private bool displayedTinyStatistics; + + private void updateLayout() + { + if (statisticsFlow.Count == 0) + return; + + float flowWidth = statisticsFlow[0].Width * statisticsFlow.Count + statisticsFlow.Spacing.X * (statisticsFlow.Count - 1); + bool tiny = !autoSize && DrawWidth < flowWidth - 20; + + if (displayedTinyStatistics != tiny) + { + if (tiny) + { + statisticsFlow.Hide(); + // Slow fade hides fill flow layout weirdness. + tinyStatisticsGrid.FadeIn(200, Easing.InQuint); + } + else + { + tinyStatisticsGrid.Hide(); + // Slow fade hides fill flow layout weirdness. + statisticsFlow.FadeIn(200, Easing.InQuint); + } + + displayedTinyStatistics = tiny; + } + } + + private void updateStatisticsSizing() => SchedulerAfterChildren.AddOnce(() => + { + if (statisticsFlow.Count == 0) + return; + + float statisticWidth = Math.Max(65, statisticsFlow.Max(s => s.LabelWidth)); + + foreach (var statistic in statisticsFlow) + { + statistic.Width = statisticWidth; + // Slow fade hides fill flow layout weirdness. + statistic.FadeIn(200, Easing.InQuint); + } + + drawSizeLayout.Invalidate(); + }); + + private void updateStatistics() => Scheduler.AddOnce(() => + { + if (statisticsFlow.Select(s => s.Value.Label) + .SequenceEqual(statistics.Select(s => s.Label))) + { + for (int i = 0; i < statistics.Count; i++) + statisticsFlow[i].Value = statistics[i]; + } + else + { + statisticsFlow.ChildrenEnumerable = statistics.Select(d => new StatisticDifficulty + { + Alpha = 0, + AccentColour = accentColour, + Value = d + }); + updateStatisticsSizing(); + } + }); + + private void updateTinyStatistics() + { + tinyStatisticsGrid.RowDimensions = statistics.Select(_ => new Dimension(GridSizeMode.AutoSize)).ToArray(); + tinyStatisticsGrid.Content = statistics.Select(s => new[] + { + new OsuSpriteText + { + Text = s.Label, + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + Colour = colourProvider.Content2, + }, + Empty(), + new OsuSpriteText + { + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + Text = s.Content ?? s.Value.ToLocalisableString("0.##"), + Colour = colourProvider.Content1, + }, + }).ToArray(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.FavouriteButton.cs new file mode 100644 index 0000000000..62ac8a07b4 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.FavouriteButton.cs @@ -0,0 +1,373 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.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.Input.Events; +using osu.Framework.Localisation; +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.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Resources.Localisation.Web; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class FavouriteButton : OsuClickableContainer + { + private readonly BindableBool isFavourite = new BindableBool(); + + private Box background = null!; + private OsuSpriteText valueText = null!; + private LoadingSpinner loadingSpinner = null!; + private Box hoverLayer = null!; + private HeartIcon icon = null!; + + private APIBeatmapSet? onlineBeatmapSet; + private PostBeatmapFavouriteRequest? favouriteRequest; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notifications { get; set; } + + internal LocalisableString Text => valueText.Text; + + public FavouriteButton() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 5; + Shear = OsuGame.SHEAR; + + AddRange(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.2f), + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = 10, Right = 10, Vertical = 5f }, + Spacing = new Vector2(4f, 0f), + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + icon = new HeartIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(OsuFont.Style.Heading2.Size), + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + Height = 20, + Children = new Drawable[] + { + loadingSpinner = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12f), + State = { Value = Visibility.Visible }, + }, + new GridContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize, minSize: 25), + }, + Content = new[] + { + new[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Heading2, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Bottom = 2f }, + AlwaysPresent = true, + }, + } + } + }, + }, + }, + }, + }, + hoverLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Colour4.White.Opacity(0.1f), + Blending = BlendingParameters.Additive, + }, + }); + Action = toggleFavourite; + } + + protected override bool OnHover(HoverEvent e) + { + hoverLayer.FadeIn(500, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + hoverLayer.FadeOut(500, Easing.OutQuint); + } + + public override LocalisableString TooltipText => isFavourite.Value ? BeatmapsetsStrings.ShowDetailsUnfavourite.ToSentence() : BeatmapsetsStrings.ShowDetailsFavourite.ToSentence(); + + // Note: `setLoading()` and `setBeatmapSet()` are called externally via their public counterparts by song select when the beatmap changes, + // as well as internally in order to display the progress and result of the (un)favourite operation when the button is clicked. + // In case of external calls, we want to cancel pending favourite requests, primarily to avoid a situation when a late success callback from an (un)favourite + // could show the favourite count from a prior beatmap. + + public void SetLoading() + { + if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting) + favouriteRequest.Cancel(); + setLoading(); + } + + private void setLoading() + { + loadingSpinner.State.Value = Visibility.Visible; + valueText.FadeOut(120, Easing.OutQuint); + + onlineBeatmapSet = null; + updateFavouriteState(); + } + + public void SetBeatmapSet(APIBeatmapSet? beatmapSet) + { + if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting) + favouriteRequest.Cancel(); + setBeatmapSet(beatmapSet); + } + + private void setBeatmapSet(APIBeatmapSet? beatmapSet, bool withHeartAnimation = false) + { + loadingSpinner.State.Value = Visibility.Hidden; + valueText.FadeIn(120, Easing.OutQuint); + + onlineBeatmapSet = beatmapSet; + updateFavouriteState(withHeartAnimation); + } + + private void updateFavouriteState(bool withAnimation = false) + { + Enabled.Value = onlineBeatmapSet != null; + + if (loadingSpinner.State.Value == Visibility.Hidden) + valueText.Text = onlineBeatmapSet?.FavouriteCount.ToLocalisableString(@"N0") ?? "-"; + + isFavourite.Value = onlineBeatmapSet?.HasFavourited == true; + + background.FadeColour(isFavourite.Value ? colours.Pink4.Darken(1f).Opacity(0.5f) : Color4.Black.Opacity(0.2f), 500, Easing.OutQuint); + valueText.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint); + icon.SetActive(isFavourite.Value, withAnimation); + } + + private void toggleFavourite() + { + Debug.Assert(onlineBeatmapSet != null); + + // having this copy locally is important to capture this particular beatmap set instance rather than the field in the request success callback, + // because if it was captured via the field / `this`, it could change value due to an external `setLoading()` or `setBeatmapSet()` call. + // there's also the part where we want to call `setLoading()` here to show the spinner, but that also sets `onlineBeatmapSet` to null. + var beatmapSet = onlineBeatmapSet; + + favouriteRequest = new PostBeatmapFavouriteRequest(beatmapSet.OnlineID, isFavourite.Value ? BeatmapFavouriteAction.UnFavourite : BeatmapFavouriteAction.Favourite); + favouriteRequest.Success += () => + { + bool hasFavourited = favouriteRequest.Action == BeatmapFavouriteAction.Favourite; + beatmapSet.HasFavourited = hasFavourited; + beatmapSet.FavouriteCount += hasFavourited ? 1 : -1; + + // if the beatmap set reference changed under the callback, abort visual updates to avoid showing stale data + if (onlineBeatmapSet == null || ReferenceEquals(beatmapSet, onlineBeatmapSet)) + setBeatmapSet(beatmapSet, withHeartAnimation: hasFavourited); + + api.LocalUserState.UpdateFavouriteBeatmapSets(); + }; + favouriteRequest.Failure += e => + { + notifications?.Post(new SimpleNotification + { + Text = e.Message, + Icon = FontAwesome.Solid.Times, + }); + + // if the beatmap set reference changed under the callback, abort visual updates to avoid showing stale data + if (onlineBeatmapSet == null || ReferenceEquals(beatmapSet, onlineBeatmapSet)) + setBeatmapSet(beatmapSet, withHeartAnimation: false); + }; + api.Queue(favouriteRequest); + setLoading(); + } + } + + private partial class HeartIcon : CompositeDrawable + { + private readonly SpriteIcon icon; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public HeartIcon() + { + InternalChildren = new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Regular.Heart, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + private const double pop_out_duration = 100; + private const double pop_in_duration = 500; + + private bool active; + + public void SetActive(bool active, bool withAnimation = false) + { + if (this.active == active) + return; + + this.active = active; + + FinishTransforms(true); + + if (active) + { + transitionIcon(FontAwesome.Solid.Heart, colours.Pink1, emphasised: withAnimation); + + if (withAnimation) + playFavouriteAnimation(); + } + else + { + transitionIcon(FontAwesome.Regular.Heart, colourProvider.Content2); + } + } + + private void transitionIcon(IconUsage newIcon, Color4 colour, bool emphasised = false) + { + icon.ScaleTo(emphasised ? 0.5f : 0.8f, pop_out_duration, Easing.OutQuad) + .Then() + .FadeColour(colour) + .Schedule(() => icon.Icon = newIcon) + .ScaleTo(1, pop_in_duration, Easing.OutElasticHalf); + } + + private void playFavouriteAnimation() + { + var circle = new FastCircle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.5f), + Blending = BlendingParameters.Additive, + Alpha = 0, + Depth = float.MinValue, + }; + + AddInternal(circle); + + circle.Delay(pop_out_duration) + .FadeTo(0.35f) + .FadeOut(1400, Easing.OutCubic) + .ScaleTo(10f, 750, Easing.OutQuint) + .Expire(); + + const int num_particles = 8; + + static float randomFloat(float min, float max) => min + Random.Shared.NextSingle() * (max - min); + + for (int i = 0; i < num_particles; i++) + { + double duration = randomFloat(600, 1000); + float angle = (i + randomFloat(0, 0.75f)) / num_particles * MathF.PI * 2; + var direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + float distance = randomFloat(DrawWidth / 2, DrawWidth); + + var particle = new FastCircle + { + Position = direction * DrawWidth / 4, + Size = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Alpha = 0, + Depth = 2, + Colour = colours.Pink, + }; + + AddInternal(particle); + + particle + .Delay(pop_out_duration) + .FadeTo(0.5f) + .MoveTo(direction * distance, 1300, Easing.OutQuint) + .FadeOut(duration, Easing.Out) + .ScaleTo(0.5f, duration) + .Expire(); + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.Statistic.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.Statistic.cs new file mode 100644 index 0000000000..85a0382360 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.Statistic.cs @@ -0,0 +1,158 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using 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 osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class Statistic : CompositeDrawable, IHasTooltip + { + private readonly IconUsage icon; + private readonly bool background; + private readonly float leftPadding; + private readonly float? minSize; + + private OsuSpriteText valueText = null!; + private LoadingSpinner loading = null!; + + private LocalisableString? text; + + public LocalisableString? Text + { + get => text; + set + { + text = value; + Scheduler.AddOnce(updateDisplay); + } + } + + public LocalisableString TooltipText { get; set; } + + public Statistic(IconUsage icon, bool background = false, float leftPadding = 10f, float? minSize = null) + { + this.icon = icon; + this.background = background; + this.leftPadding = leftPadding; + this.minSize = minSize; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 5; + Shear = background ? OsuGame.SHEAR : Vector2.Zero; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = background ? 0.2f : 0f, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = background ? leftPadding : 0, Right = background ? 10f : 0f, Vertical = 5f }, + Spacing = new Vector2(4f, 0f), + Shear = background ? -OsuGame.SHEAR : Vector2.Zero, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = icon, + Size = new Vector2(OsuFont.Style.Heading2.Size), + Colour = colourProvider.Content2, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + Height = 20, + Children = new Drawable[] + { + loading = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(14f), + State = { Value = Visibility.Visible }, + }, + new GridContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize, minSize: minSize ?? 0), + }, + Content = new[] + { + new[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Heading2, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Bottom = 2f }, + AlwaysPresent = true, + }, + } + } + }, + }, + }, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Scheduler.AddOnce(updateDisplay); + } + + private void updateDisplay() + { + loading.State.Value = text != null ? Visibility.Hidden : Visibility.Visible; + + if (text != null) + { + valueText.Text = text.Value; + valueText.FadeIn(120, Easing.OutQuint); + } + else + valueText.FadeOut(120, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticDifficulty.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticDifficulty.cs new file mode 100644 index 0000000000..bcce78246d --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticDifficulty.cs @@ -0,0 +1,209 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Difficulty; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class StatisticDifficulty : CompositeDrawable, IHasAccentColour, IHasCustomTooltip + { + private Data value = new Data(string.Empty, 0, 0, 0); + + public Data Value + { + get => value; + set + { + this.value = value; + + if (IsLoaded) + updateDisplay(); + } + } + + public float LabelWidth => labelText.DrawWidth; + + private readonly Circle bar; + private readonly Circle adjustedBar; + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText valueText; + private readonly SpriteIcon valueIcon; + private readonly Container bars; + + public Color4 AccentColour + { + get => bar.Colour; + set => bar.Colour = value; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public StatisticDifficulty() + { + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + bars = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + new Circle + { + RelativeSizeAxes = Axes.X, + Height = 2f, + Colour = Color4.Black, + Masking = true, + CornerRadius = 1f, + Depth = float.MaxValue, + }, + bar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 2f, + Masking = true, + CornerRadius = 1f, + }, + adjustedBar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 2f, + Masking = true, + CornerRadius = 1f, + }, + }, + }, + labelText = new TruncatingSpriteText + { + Margin = new MarginPadding { Top = 2f }, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + MaxWidth = 85, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Body, + }, + valueIcon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding + { + Top = -4f, + Left = 2, + }, + Size = new Vector2(8), + } + }, + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + labelText.Colour = colourProvider.Content2; + valueText.Colour = colourProvider.Content1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + private void updateDisplay() + { + bar.ResizeWidthTo(value.Maximum == 0 ? 0 : Math.Clamp(value.Value / value.Maximum, 0, 1), 300, Easing.OutQuint); + adjustedBar.ResizeWidthTo(value.Maximum == 0 ? 0 : Math.Clamp(value.AdjustedValue / value.Maximum, 0, 1), 300, Easing.OutQuint); + + labelText.Text = value.Label; + valueText.Text = value.Content ?? value.AdjustedValue.ToLocalisableString("0.##"); + + if (value.Value == value.AdjustedValue) + { + adjustedBar.FadeColour(Color4.Transparent, 300, Easing.OutQuint); + bar.FadeIn(300, Easing.OutQuint); + + valueText.FadeColour(Color4.White, 300, Easing.OutQuint); + valueIcon.Hide(); + } + else + { + bool difficultyIncrease = value.Value < value.AdjustedValue; + + if (difficultyIncrease) + { + bars.ChangeChildDepth(adjustedBar, 1); + bar.FadeIn(300, Easing.OutQuint); + adjustedBar.FadeColour(ColourInfo.GradientHorizontal(Color4.Black, colours.Red1), 300, Easing.OutQuint); + + valueText.FadeColour(colours.Red1, 300, Easing.OutQuint); + valueIcon.Show(); + valueIcon.Colour = colours.Red1; + valueIcon.Icon = FontAwesome.Solid.SortUp; + } + else + { + bar.FadeTo(0.5f, 300, Easing.OutQuint); + bars.ChangeChildDepth(adjustedBar, -1); + adjustedBar.FadeColour(colours.Lime1, 300, Easing.OutQuint); + + valueText.FadeColour(colours.Lime1, 300, Easing.OutQuint); + valueIcon.Show(); + valueIcon.Colour = colours.Lime1; + valueIcon.Icon = FontAwesome.Solid.SortDown; + } + } + } + + public record Data(LocalisableString Label, float Value, float AdjustedValue, float Maximum, string? Content = null, RulesetBeatmapAttribute? BeatmapAttribute = null) + { + public Data(RulesetBeatmapAttribute attribute) + : this(attribute.Label, attribute.OriginalValue, attribute.AdjustedValue, attribute.MaxValue, BeatmapAttribute: attribute) + { + } + } + + public ITooltip GetCustomTooltip() => new BeatmapAttributeTooltip(); + public RulesetBeatmapAttribute? TooltipContent => value.BeatmapAttribute; + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticPlayCount.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticPlayCount.cs new file mode 100644 index 0000000000..d193cbe286 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.StatisticPlayCount.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 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.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.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class StatisticPlayCount : Statistic, IHasCustomTooltip + { + public Data? Value + { + set + { + base.Text = value?.Total < 0 ? "-" : value?.Total.ToLocalisableString("N0"); + TooltipContent = value; + } + } + + public new LocalisableString? Text + { + set => throw new InvalidOperationException($"Use {nameof(Value)} instead."); + } + + public Data? TooltipContent { get; private set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public StatisticPlayCount(bool background = false, float leftPadding = 10, float? minSize = null) + : base(OsuIcon.Play, background, leftPadding, minSize) + { + } + + ITooltip IHasCustomTooltip.GetCustomTooltip() => new PlayCountTooltip(colourProvider); + + public record Data(int Total, int User); + + private partial class PlayCountTooltip : VisibilityContainer, ITooltip + { + private readonly OverlayColourProvider colourProvider; + + private OsuSpriteText totalPlaysText = null!; + private OsuSpriteText personalPlaysText = null!; + + public PlayCountTooltip(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = 10; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 10f, + }; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding(10), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(16f, 0f), + Children = new[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Text = SongSelectStrings.TotalPlays, + }, + totalPlaysText = new OsuSpriteText + { + Colour = colourProvider.Content1, + Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular), + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Text = SongSelectStrings.PersonalPlays, + }, + personalPlaysText = new OsuSpriteText + { + Colour = colourProvider.Content1, + Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular), + }, + } + }, + } + }, + }; + } + + public void SetContent(Data content) + { + totalPlaysText.Text = content.Total < 0 ? "-" : content.Total.ToLocalisableString("N0"); + personalPlaysText.Text = content.User < 0 ? "-" : content.User.ToLocalisableString("N0"); + } + + public void Move(Vector2 pos) => Position = pos; + + protected override void PopIn() => this.FadeIn(300, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(300, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs new file mode 100644 index 0000000000..a74872eaa7 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -0,0 +1,342 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +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.Configuration; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge : VisibilityContainer + { + private const float corner_radius = 10; + + [Resolved] + private IBindable working { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private IBindable onlineLookupResult { get; set; } = null!; + + protected override bool StartHidden => true; + + private ModSettingChangeTracker? settingChangeTracker; + + private BeatmapSetOnlineStatusPill statusPill = null!; + private OsuHoverContainer titleLink = null!; + private MarqueeContainer titleLabel = null!; + private OsuHoverContainer artistLink = null!; + private MarqueeContainer artistLabel = null!; + + internal string DisplayedTitle { get; private set; } = string.Empty; + internal string DisplayedArtist { get; private set; } = string.Empty; + + private StatisticPlayCount playCount = null!; + private FavouriteButton favouriteButton = null!; + private Statistic lengthStatistic = null!; + private Statistic bpmStatistic = null!; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private LocalisationManager localisation { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private FillFlowContainer statisticsFlow = null!; + + public BeatmapTitleWedge() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = corner_radius; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding + { + Top = SongSelect.WEDGE_CONTENT_MARGIN, + Left = SongSelect.WEDGE_CONTENT_MARGIN + }, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new ShearAligningWrapper(statusPill = new BeatmapSetOnlineStatusPill + { + Shear = -OsuGame.SHEAR, + ShowUnknownStatus = true, + TextSize = OsuFont.Style.Caption1.Size, + TextPadding = new MarginPadding { Horizontal = 6, Vertical = 1 }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Title.Size, + Margin = new MarginPadding { Bottom = -4f }, + Child = titleLink = new OsuHoverContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = titleLabel = new MarqueeContainer + { + OverflowSpacing = 50, + } + } + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Heading2.Size, + Margin = new MarginPadding { Left = 1f }, + Child = artistLink = new OsuHoverContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = artistLabel = new MarqueeContainer + { + OverflowSpacing = 50, + } + } + }), + new ShearAligningWrapper(statisticsFlow = new FillFlowContainer + { + Shear = -OsuGame.SHEAR, + AutoSizeAxes = Axes.X, + Height = 30, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2f, 0f), + Children = new Drawable[] + { + playCount = new StatisticPlayCount(background: true, leftPadding: SongSelect.WEDGE_CONTENT_MARGIN, minSize: 50f) + { + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + }, + favouriteButton = new FavouriteButton(), + lengthStatistic = new Statistic(OsuIcon.Clock), + bpmStatistic = new Statistic(OsuIcon.Metronome) + { + TooltipText = BeatmapsetsStrings.ShowStatsBpm, + Margin = new MarginPadding { Left = 5f }, + }, + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + Padding = new MarginPadding { Right = -SongSelect.WEDGE_CONTENT_MARGIN }, + Child = new DifficultyDisplay(), + }), + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + working.BindValueChanged(_ => updateDisplay()); + ruleset.BindValueChanged(_ => updateDisplay()); + onlineLookupResult.BindValueChanged(_ => updateDisplay()); + + mods.BindValueChanged(m => + { + settingChangeTracker?.Dispose(); + + updateLengthAndBpmStatistics(); + + settingChangeTracker = new ModSettingChangeTracker(m.NewValue); + settingChangeTracker.SettingChanged += _ => updateLengthAndBpmStatistics(); + }); + + updateDisplay(); + + statisticsFlow.AutoSizeDuration = 100; + statisticsFlow.AutoSizeEasing = Easing.OutQuint; + } + + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(-150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + private void updateDisplay() + { + var metadata = working.Value.Metadata; + var beatmapInfo = working.Value.BeatmapInfo; + + statusPill.Status = beatmapInfo.Status; + + var titleText = new RomanisableString(metadata.TitleUnicode, metadata.Title); + titleLabel.CreateContent = () => new OsuSpriteText + { + Text = titleText, + Shadow = true, + Font = OsuFont.Style.Title, + }; + titleLink.Action = () => songSelect?.Search(titleText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)); + DisplayedTitle = titleText.ToString(); + + var artistText = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); + artistLabel.CreateContent = () => new OsuSpriteText + { + Text = artistText, + Shadow = true, + Font = OsuFont.Style.Heading2, + }; + artistLink.Action = () => songSelect?.Search(artistText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)); + DisplayedArtist = artistText.ToString(); + + updateLengthAndBpmStatistics(); + updateOnlineDisplay(); + } + + private CancellationTokenSource? lengthBpmCancellationSource; + + private void updateLengthAndBpmStatistics() + { + lengthBpmCancellationSource?.Cancel(); + lengthBpmCancellationSource = new CancellationTokenSource(); + + var token = lengthBpmCancellationSource.Token; + + Task.Run(() => + { + var beatmapInfo = working.Value.BeatmapInfo; + // This can take time as it is a synchronous task. + var beatmap = working.Value.Beatmap; + + double rate = ModUtils.CalculateRateWithMods(mods.Value); + + int bpmMax = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMaximum, rate); + int bpmMin = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMinimum, rate); + int mostCommonBPM = FormatUtils.RoundBPM(60000 / beatmap.GetMostCommonBeatLength(), rate); + + double drainLength = Math.Round(beatmap.CalculateDrainLength() / rate); + double hitLength = Math.Round(beatmapInfo.Length / rate); + + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + lengthStatistic.Text = hitLength.ToFormattedDuration(); + lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); + + bpmStatistic.Text = bpmMin == bpmMax + ? $"{bpmMin}" + : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; + }); + }, token); + } + + private CancellationTokenSource? onlineDisplayCancellationSource; + + private void updateOnlineDisplay() + { + onlineDisplayCancellationSource?.Cancel(); + onlineDisplayCancellationSource = null; + + if (onlineLookupResult.Value?.Status != SongSelect.BeatmapSetLookupStatus.Completed) + { + playCount.Value = null; + favouriteButton.SetLoading(); + } + else + { + var onlineBeatmap = onlineLookupResult.Value.Result?.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID); + playCount.Value = new StatisticPlayCount.Data(onlineBeatmap?.PlayCount ?? -1, onlineBeatmap?.UserPlayCount ?? -1); + favouriteButton.SetBeatmapSet(onlineLookupResult.Value.Result); + + onlineDisplayCancellationSource = new CancellationTokenSource(); + var token = onlineDisplayCancellationSource.Token; + + // the online fetch may have also updated the beatmap's status. + // this needs to be checked against the *local* beatmap model rather than the online one, because it's not known here whether the status change has occurred or not + // (think scenarios like the beatmap being locally modified). + // it also has to be handled explicitly like this because the working beatmap's `BeatmapInfo` will not receive these updates due to being detached + // (and because of https://github.com/ppy/osu/blob/4b73afd1957a9161e2956fc4191c8114d9958372/osu.Game/Screens/SelectV2/SongSelect.cs#L487-L488 + // which prevents working beatmap refetches caused by changes to the realm model of perceived low importance). + realm.RunAsync(r => + { + var refetchedBeatmap = r.Find(working.Value.BeatmapInfo.ID); + return refetchedBeatmap?.Status; + }, token).ContinueWith(t => + { + var status = t.GetResultSafely(); + + if (status != null) + { + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + statusPill.Status = status.Value; + }); + } + }, token); + } + } + + protected override void Dispose(bool isDisposing) + { + onlineDisplayCancellationSource?.Dispose(); + onlineDisplayCancellationSource = null; + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/SelectV2/CollectionDropdown.cs b/osu.Game/Screens/SelectV2/CollectionDropdown.cs new file mode 100644 index 0000000000..9f1950ac5f --- /dev/null +++ b/osu.Game/Screens/SelectV2/CollectionDropdown.cs @@ -0,0 +1,256 @@ +// Copyright (c) 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osuTK; +using Realms; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// A dropdown to select the collection to be used to filter results. + /// + public partial class CollectionDropdown : ShearedDropdown // TODO: partial class under FilterControl? + { + /// + /// Whether to show the "manage collections..." menu item in the dropdown. + /// + protected virtual bool ShowManageCollectionsItem => true; + + private readonly BindableList filters = new BindableList(); + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IDisposable? realmSubscription; + + private readonly CollectionFilterMenuItem allBeatmapsItem = new AllBeatmapsCollectionFilterMenuItem(); + + public CollectionDropdown() + : base(CollectionsStrings.Collection) + { + ItemSource = filters; + + Current.Value = allBeatmapsItem; + AlwaysShowSearchBar = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); + + Current.BindValueChanged(selectionChanged); + } + + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) + { + if (changes == null) + { + filters.Clear(); + filters.Add(allBeatmapsItem); + filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); + if (ShowManageCollectionsItem) + filters.Add(new ManageCollectionsFilterMenuItem()); + } + else + { + foreach (int i in changes.DeletedIndices.OrderDescending()) + filters.RemoveAt(i + 1); + + foreach (int i in changes.InsertedIndices) + filters.Insert(i + 1, new CollectionFilterMenuItem(collections[i].ToLive(realm))); + + var selectedItem = SelectedItem?.Value; + + foreach (int i in changes.NewModifiedIndices) + { + var updatedItem = collections[i]; + + // This is responsible for updating the state of the +/- button and the collection's name. + // TODO: we can probably make the menu items update with changes to avoid this. + filters.RemoveAt(i + 1); + filters.Insert(i + 1, new CollectionFilterMenuItem(updatedItem.ToLive(realm))); + + if (updatedItem.ID == selectedItem?.Collection?.ID) + { + // This current update and schedule is required to work around dropdown headers not updating text even when the selected item + // changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue + // a warning that it's going to be a frustrating journey. + Current.Value = allBeatmapsItem; + Schedule(() => + { + // current may have changed before the scheduled call is run. + if (Current.Value != allBeatmapsItem) + return; + + Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0]; + }); + + break; + } + } + } + } + + private void selectionChanged(ValueChangedEvent filter) + { + // May be null during .Clear(). + if (filter.NewValue.IsNull()) + return; + + // Never select the manage collection filter - rollback to the previous filter. + // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. + if (filter.NewValue is ManageCollectionsFilterMenuItem) + { + Current.Value = filter.OldValue; + manageCollectionsDialog?.Show(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + realmSubscription?.Dispose(); + } + + protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName; + + protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); + + protected virtual ShearedCollectionDropdownMenu CreateCollectionMenu() => new ShearedCollectionDropdownMenu(); + + protected partial class ShearedCollectionDropdownMenu : ShearedDropdownMenu + { + public ShearedCollectionDropdownMenu() + { + MaxHeight = 200; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableCollectionMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; + } + + protected partial class DrawableCollectionMenuItem : ShearedDropdownMenu.ShearedMenuItem + { + private IconButton addOrRemoveButton = null!; + + private bool beatmapInCollection; + + private readonly Live? collection; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public DrawableCollectionMenuItem(MenuItem item) + : base(item) + { + collection = ((DropdownMenuItem)item).Value.Collection; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(addOrRemoveButton = new NoFocusChangeIconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Shear = -OsuGame.SHEAR, + X = -OsuScrollContainer.SCROLL_BAR_WIDTH, + Scale = new Vector2(0.65f), + Action = addOrRemove, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (collection != null) + { + beatmap.BindValueChanged(_ => + { + beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash)); + + addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; + addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; + addOrRemoveButton.TooltipText = beatmapInCollection ? CollectionsStrings.RemoveSelectedBeatmap : CollectionsStrings.AddSelectedBeatmap; + + updateButtonVisibility(); + }, true); + } + + updateButtonVisibility(); + } + + protected override bool OnHover(HoverEvent e) + { + updateButtonVisibility(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateButtonVisibility(); + base.OnHoverLost(e); + } + + protected override void OnSelectChange() + { + base.OnSelectChange(); + updateButtonVisibility(); + } + + private void updateButtonVisibility() + { + if (collection == null) + addOrRemoveButton.Alpha = 0; + else + addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; + } + + private void addOrRemove() + { + Debug.Assert(collection != null); + + Task.Run(() => collection.PerformWrite(c => + { + if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) + c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); + })); + } + + protected override Drawable CreateContent() => (Content)base.CreateContent(); + + private partial class NoFocusChangeIconButton : IconButton + { + public override bool ChangeFocusOnClick => false; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/FilterControl.DifficultyRangeSlider.cs b/osu.Game/Screens/SelectV2/FilterControl.DifficultyRangeSlider.cs new file mode 100644 index 0000000000..f65c17bddf --- /dev/null +++ b/osu.Game/Screens/SelectV2/FilterControl.DifficultyRangeSlider.cs @@ -0,0 +1,182 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Utils; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class FilterControl + { + public partial class DifficultyRangeSlider : ShearedRangeSlider + { + private Container borderContainer = null!; + + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + private static readonly (float, Color4)[] spectrum = OsuColour.STAR_DIFFICULTY_SPECTRUM + .Skip(1) + .Prepend((0.0f, OsuColour.STAR_DIFFICULTY_SPECTRUM.ElementAt(1).Item2)).ToArray(); + + public DifficultyRangeSlider() + : base(BeatmapsetsStrings.ShowStatsStars) + { + NubWidth = ShearedNub.HEIGHT * 1.16f; + DefaultStringUpperBound = "∞"; + + AddLayout(drawSizeLayout); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + SliderContainer.AddRange(new Drawable[] + { + new Container + { + Depth = 1, + RelativeSizeAxes = Axes.Both, + Shear = OsuGame.SHEAR, + CornerRadius = 5f, + Masking = true, + ChildrenEnumerable = spectrum.Zip(spectrum.Skip(1)) + .Select(p => new Box + { + RelativePositionAxes = Axes.X, + X = p.First.Item1 / 10f, + RelativeSizeAxes = Axes.Both, + Width = (p.Second.Item1 - p.First.Item1) / 10f, + Colour = ColourInfo.GradientHorizontal(p.First.Item2, p.Second.Item2), + }), + }, + borderContainer = new Container + { + Depth = -1, + RelativePositionAxes = Axes.X, + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + BorderColour = colourProvider.Highlight1, + BorderThickness = 2, + Masking = true, + Shear = OsuGame.SHEAR, + CornerRadius = 5f, + Child = new Box + { + Colour = Color4.Transparent, + RelativeSizeAxes = Axes.Both, + } + }, + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + LowerBoundSlider.Current.ValueChanged += _ => updateBorderDisplay(false); + UpperBoundSlider.Current.ValueChanged += _ => updateBorderDisplay(false); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!drawSizeLayout.IsValid) + { + updateBorderDisplay(true); + drawSizeLayout.Validate(); + } + } + + private void updateBorderDisplay(bool instant) + { + float borderStart = LowerBoundSlider.NormalizedValue * LowerBoundSlider.UsableWidth / LowerBoundSlider.DrawWidth; + float borderEnd = UpperBoundSlider.NormalizedValue * UpperBoundSlider.UsableWidth / UpperBoundSlider.DrawWidth; + borderEnd += UpperBoundSlider.NubWidth / UpperBoundSlider.DrawWidth; + + borderContainer.MoveToX(borderStart, instant ? 0 : 250, Easing.OutQuint); + borderContainer.ResizeWidthTo(borderEnd - borderStart, instant ? 0 : 250, Easing.OutQuint); + } + + protected override BoundSliderBar CreateBoundSlider(bool isUpper) => new DifficultyBoundSliderBar(this, isUpper); + + private partial class DifficultyBoundSliderBar : BoundSliderBar + { + private readonly bool isUpper; + + protected override bool FocusIndicator => false; + + public override LocalisableString TooltipText + { + get + { + if (Current.IsDefault && isUpper) + return UserInterfaceStrings.NoLimit; + + return SongSelectStrings.Stars(Current.Value.ToLocalisableString(@"0.##")); + } + } + + public DifficultyBoundSliderBar(ShearedRangeSlider slider, bool isUpper) + : base(slider, isUpper) + { + this.isUpper = isUpper; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (isUpper) + { + LeftBox.Colour = OsuColour.Gray(0.4f).Opacity(0.2f); + RightBox.Colour = OsuColour.Gray(0.05f).Opacity(0.7f); + } + else + { + LeftBox.Colour = OsuColour.Gray(0.05f).Opacity(0.7f); + RightBox.Colour = OsuColour.Gray(0.4f).Opacity(0.2f); + } + } + + protected override void UpdateDisplay(double value) + { + Colour4 nubColour = ColourUtils.SampleFromLinearGradient(spectrum, (float)Math.Round(value, 2, MidpointRounding.AwayFromZero)); + nubColour = nubColour.Lighten(0.4f); + + if (value >= 8.0) + nubColour = colours.Gray4; + + Nub.AccentColour = nubColour; + Nub.GlowingAccentColour = nubColour.Lighten(0.2f); + Nub.ShadowColour = Color4.Black.Opacity(0.2f); + NubText.Colour = OsuColour.ForegroundTextColourFor(nubColour); + + base.UpdateDisplay(value); + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs new file mode 100644 index 0000000000..a90ac3a4e8 --- /dev/null +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -0,0 +1,335 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Collections; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class FilterControl : OverlayContainer + { + // taken from draw visualiser. used for carousel alignment purposes. + public const float HEIGHT_FROM_SCREEN_TOP = 141 - corner_radius; + + private const float corner_radius = 10; + + private SongSelectSearchTextBox searchTextBox = null!; + private ShearedToggleButton showConvertedBeatmapsButton = null!; + private DifficultyRangeSlider difficultyRangeSlider = null!; + private ShearedDropdown sortDropdown = null!; + private ShearedDropdown groupDropdown = null!; + private CollectionDropdown collectionDropdown = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IBindable localUser = null!; + private readonly IBindableList localUserFavouriteBeatmapSets = new BindableList(); + + public LocalisableString StatusText + { + get => searchTextBox.StatusText; + set => searchTextBox.StatusText = value; + } + + public event Action? CriteriaChanged; + + private FilterCriteria currentCriteria = null!; + + private IDisposable? collectionsSubscription; + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Shear = OsuGame.SHEAR; + Margin = new MarginPadding { Top = -corner_radius, Right = -40 }; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = corner_radius, + Masking = true, + Child = new WedgeBackground + { + Anchor = Anchor.TopRight, + Scale = new Vector2(-1, 1), + } + }, + new ReverseChildIDFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Padding = new MarginPadding { Top = corner_radius + 5, Bottom = 2, Right = 40f, Left = 2f }, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Child = searchTextBox = new SongSelectSearchTextBox + { + RelativeSizeAxes = Axes.X, + HoldFocus = true, + }, + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute), // can probably be removed? + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + difficultyRangeSlider = new DifficultyRangeSlider + { + RelativeSizeAxes = Axes.X, + MinRange = 0.1f, + }, + Empty(), + showConvertedBeatmapsButton = new ShearedToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = UserInterfaceStrings.ShowConverts, + Height = 30f, + }, + }, + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + Height = 30, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(maxSize: 180), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(maxSize: 180), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + }, + Content = new[] + { + new[] + { + sortDropdown = new ShearedDropdown(SongSelectStrings.Sort) + { + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), + }, + Empty(), + groupDropdown = new ShearedDropdown(SongSelectStrings.Group) + { + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), + }, + Empty(), + collectionDropdown = new CollectionDropdown + { + RelativeSizeAxes = Axes.X, + }, + } + } + }, + }, + } + }; + + localUser = api.LocalUser.GetBoundCopy(); + localUserFavouriteBeatmapSets.BindTo(api.LocalUserState.FavouriteBeatmapSets); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + difficultyRangeSlider.LowerBound = config.GetBindable(OsuSetting.DisplayStarsMinimum); + difficultyRangeSlider.UpperBound = config.GetBindable(OsuSetting.DisplayStarsMaximum); + config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmapsButton.Active); + config.BindWith(OsuSetting.SongSelectSortingMode, sortDropdown.Current); + config.BindWith(OsuSetting.SongSelectGroupMode, groupDropdown.Current); + + ruleset.BindValueChanged(_ => updateCriteria()); + mods.BindValueChanged(m => + { + // The following is a note carried from old song select and may not be a valid reason anymore: + // // Mods are updated once by the mod select overlay when song select is entered, + // // regardless of if there are any mods or any changes have taken place. + // // Updating the criteria here so early triggers a re-ordering of panels on song select, via... some mechanism. + // // Todo: Investigate/fix and potentially remove this. + // TODO: this might be simply removable with the new song select & carousel code. + if (m.NewValue.SequenceEqual(m.OldValue)) + return; + + var rulesetCriteria = currentCriteria.RulesetCriteria; + if (rulesetCriteria?.FilterMayChangeFromMods(m) == true) + updateCriteria(); + }); + + searchTextBox.Current.BindValueChanged(_ => updateCriteria()); + difficultyRangeSlider.LowerBound.BindValueChanged(_ => updateCriteria()); + difficultyRangeSlider.UpperBound.BindValueChanged(_ => updateCriteria()); + showConvertedBeatmapsButton.Active.BindValueChanged(_ => updateCriteria()); + sortDropdown.Current.BindValueChanged(_ => updateCriteria()); + groupDropdown.Current.BindValueChanged(_ => updateCriteria()); + collectionDropdown.Current.BindValueChanged(v => + { + // The hope would be that this never arrives here, but due to bindings receiving changes before + // local ValueChanged events, that's not the case (see https://github.com/ppy/osu-framework/pull/1545). + if (v.NewValue is ManageCollectionsFilterMenuItem || v.OldValue is ManageCollectionsFilterMenuItem) + return; + + updateCriteria(); + }); + collectionsSubscription = realm.RegisterForNotifications(r => r.All(), (collections, changeSet) => + { + if (changeSet != null && groupDropdown.Current.Value == GroupMode.Collections) + updateCriteria(); + }); + + localUser.BindValueChanged(_ => updateCriteria()); + localUserFavouriteBeatmapSets.BindCollectionChanged((_, _) => updateCriteria()); + + updateCriteria(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + collectionsSubscription?.Dispose(); + } + + /// + /// Creates a based on the current state of the controls. + /// + public FilterCriteria CreateCriteria() + { + string query = searchTextBox.Current.Value; + bool isValidUser = localUser.Value.Id > 1; + + var criteria = new FilterCriteria + { + Sort = sortDropdown.Current.Value, + Group = groupDropdown.Current.Value, + AllowConvertedBeatmaps = showConvertedBeatmapsButton.Active.Value, + Ruleset = ruleset.Value, + Mods = mods.Value, + CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes).ToImmutableHashSet(), + LocalUserId = isValidUser ? localUser.Value.Id : null, + LocalUserUsername = isValidUser ? localUser.Value.Username : null, + }; + + if (!difficultyRangeSlider.LowerBound.IsDefault) + criteria.UserStarDifficulty.Min = difficultyRangeSlider.LowerBound.Value; + + if (!difficultyRangeSlider.UpperBound.IsDefault) + criteria.UserStarDifficulty.Max = difficultyRangeSlider.UpperBound.Value; + + criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + return criteria; + } + + private void updateCriteria() + { + currentCriteria = CreateCriteria(); + CriteriaChanged?.Invoke(currentCriteria); + } + + /// + /// Set the query to the search text box. + /// + /// The string to search. + public void Search(string query) + { + searchTextBox.Current.Value = query; + } + + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + internal partial class SongSelectSearchTextBox : ShearedFilterTextBox + { + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox(); + + private partial class InnerTextBox : InnerFilterTextBox + { + public override bool HandleLeftRightArrows => false; + + public override bool OnPressed(KeyBindingPressEvent e) + { + // Conflicts with default group navigation keys (shift-left shift-right). + if (e.Action == PlatformAction.SelectBackwardChar || e.Action == PlatformAction.SelectForwardChar) + return false; + + // the "cut" platform key binding (shift-delete) conflicts with the beatmap deletion action. + if (e.Action == PlatformAction.Cut && e.ShiftPressed && e.CurrentState.Keyboard.Keys.IsPressed(Key.Delete)) + return false; + + return base.OnPressed(e); + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/Footer/BeatmapOptionsPopover.cs b/osu.Game/Screens/SelectV2/Footer/BeatmapOptionsPopover.cs deleted file mode 100644 index fb2e32dfdc..0000000000 --- a/osu.Game/Screens/SelectV2/Footer/BeatmapOptionsPopover.cs +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Collections; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Localisation; -using osu.Game.Overlays; -using osuTK; -using osuTK.Graphics; -using osuTK.Input; -using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; - -namespace osu.Game.Screens.SelectV2.Footer -{ - public partial class BeatmapOptionsPopover : OsuPopover - { - private FillFlowContainer buttonFlow = null!; - private readonly ScreenFooterButtonOptions footerButton; - - [Cached] - private readonly OverlayColourProvider colourProvider; - - private WorkingBeatmap beatmapWhenOpening = null!; - - [Resolved] - private IBindable beatmap { get; set; } = null!; - - public BeatmapOptionsPopover(ScreenFooterButtonOptions footerButton, OverlayColourProvider colourProvider) - { - this.footerButton = footerButton; - this.colourProvider = colourProvider; - } - - [BackgroundDependencyLoader] - private void load(ManageCollectionsDialog? manageCollectionsDialog, OsuColour colours, BeatmapManager? beatmapManager) - { - Content.Padding = new MarginPadding(5); - - Child = buttonFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(3), - }; - - beatmapWhenOpening = beatmap.Value; - - addHeader(CommonStrings.General); - addButton(SongSelectStrings.ManageCollections, FontAwesome.Solid.Book, () => manageCollectionsDialog?.Show()); - - addHeader(SongSelectStrings.ForAllDifficulties, beatmapWhenOpening.BeatmapSetInfo.ToString()); - addButton(SongSelectStrings.DeleteBeatmap, FontAwesome.Solid.Trash, () => { }, colours.Red1); // songSelect?.DeleteBeatmap(beatmapWhenOpening.BeatmapSetInfo); - - addHeader(SongSelectStrings.ForSelectedDifficulty, beatmapWhenOpening.BeatmapInfo.DifficultyName); - // TODO: make work, and make show "unplayed" or "played" based on status. - addButton(SongSelectStrings.MarkAsPlayed, FontAwesome.Regular.TimesCircle, null); - addButton(SongSelectStrings.ClearAllLocalScores, FontAwesome.Solid.Eraser, () => { }, colours.Red1); // songSelect?.ClearScores(beatmapWhenOpening.BeatmapInfo); - - // if (songSelect != null && songSelect.AllowEditing) - addButton(SongSelectStrings.EditBeatmap, FontAwesome.Solid.PencilAlt, () => { }); // songSelect.Edit(beatmapWhenOpening.BeatmapInfo); - - addButton(WebCommonStrings.ButtonsHide.ToSentence(), FontAwesome.Solid.Magic, () => beatmapManager?.Hide(beatmapWhenOpening.BeatmapInfo)); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(this)); - - beatmap.BindValueChanged(_ => Hide()); - } - - private void addHeader(LocalisableString text, string? context = null) - { - var textFlow = new OsuTextFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding(10), - }; - - textFlow.AddText(text, t => t.Font = OsuFont.Default.With(weight: FontWeight.SemiBold)); - - if (context != null) - { - textFlow.NewLine(); - textFlow.AddText(context, t => - { - t.Colour = colourProvider.Content2; - t.Font = t.Font.With(size: 13); - }); - } - - buttonFlow.Add(textFlow); - } - - private void addButton(LocalisableString text, IconUsage icon, Action? action, Color4? colour = null) - { - var button = new OptionButton - { - Text = text, - Icon = icon, - TextColour = colour, - Action = () => - { - Scheduler.AddDelayed(Hide, 50); - action?.Invoke(); - }, - }; - - buttonFlow.Add(button); - } - - private partial class OptionButton : OsuButton - { - public IconUsage Icon { get; init; } - public Color4? TextColour { get; init; } - - public OptionButton() - { - Size = new Vector2(265, 50); - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - BackgroundColour = colourProvider.Background3; - - SpriteText.Colour = TextColour ?? Color4.White; - Content.CornerRadius = 10; - - Add(new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(17), - X = 15, - Icon = Icon, - Colour = TextColour ?? Color4.White, - }); - } - - protected override SpriteText CreateText() => new OsuSpriteText - { - Depth = -1, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - X = 40 - }; - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - // don't absorb control as ToolbarRulesetSelector uses control + number to navigate - if (e.ControlPressed) return false; - - if (!e.Repeat && e.Key >= Key.Number1 && e.Key <= Key.Number9) - { - int requested = e.Key - Key.Number1; - - OptionButton? found = buttonFlow.Children.OfType().ElementAtOrDefault(requested); - - if (found != null) - { - found.TriggerClick(); - return true; - } - } - - return base.OnKeyDown(e); - } - - protected override void UpdateState(ValueChangedEvent state) - { - base.UpdateState(state); - footerButton.OverlayState.Value = state.NewValue; - } - } -} diff --git a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonOptions.cs b/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonOptions.cs deleted file mode 100644 index 72409b5566..0000000000 --- a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonOptions.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Input.Bindings; -using osu.Game.Overlays; -using osu.Game.Screens.Footer; - -namespace osu.Game.Screens.SelectV2.Footer -{ - public partial class ScreenFooterButtonOptions : ScreenFooterButton, IHasPopover - { - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - Text = "Options"; - Icon = FontAwesome.Solid.Cog; - AccentColour = colour.Purple1; - Hotkey = GlobalAction.ToggleBeatmapOptions; - - Action = this.ShowPopover; - } - - public Popover GetPopover() => new BeatmapOptionsPopover(this, colourProvider); - } -} diff --git a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonMods.cs b/osu.Game/Screens/SelectV2/FooterButtonMods.cs similarity index 76% rename from osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonMods.cs rename to osu.Game/Screens/SelectV2/FooterButtonMods.cs index 0992203dbc..4720c11731 100644 --- a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonMods.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonMods.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; @@ -27,11 +28,14 @@ using osu.Game.Screens.Play.HUD; using osu.Game.Utils; using osuTK; using osuTK.Graphics; +using osuTK.Input; -namespace osu.Game.Screens.SelectV2.Footer +namespace osu.Game.Screens.SelectV2 { - public partial class ScreenFooterButtonMods : ScreenFooterButton, IHasCurrentValue> + public partial class FooterButtonMods : ScreenFooterButton, IHasCurrentValue> { + public Action? RequestDeselectAllMods { get; init; } + private const float bar_height = 30f; private const float mod_display_portion = 0.65f; @@ -48,9 +52,12 @@ namespace osu.Game.Screens.SelectV2.Footer private Drawable unrankedBadge = null!; private ModDisplay modDisplay = null!; - private OsuSpriteText modCountText = null!; - protected OsuSpriteText MultiplierText { get; private set; } = null!; + private OsuSpriteText multiplierText { get; set; } = null!; + + private Container modContainer = null!; + + private ModCountText overflowModCountDisplay = null!; [Resolved] private OsuColour colours { get; set; } = null!; @@ -58,7 +65,7 @@ namespace osu.Game.Screens.SelectV2.Footer [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public ScreenFooterButtonMods(ModSelectOverlay overlay) + public FooterButtonMods(ModSelectOverlay overlay) : base(overlay) { } @@ -66,7 +73,7 @@ namespace osu.Game.Screens.SelectV2.Footer [BackgroundDependencyLoader] private void load() { - Text = "Mods"; + Text = SongSelectStrings.Mods; Icon = FontAwesome.Solid.ExchangeAlt; AccentColour = colours.Lime1; @@ -78,7 +85,7 @@ namespace osu.Game.Screens.SelectV2.Footer Y = -5f, Depth = float.MaxValue, Origin = Anchor.BottomLeft, - Shear = BUTTON_SHEAR, + Shear = OsuGame.SHEAR, CornerRadius = CORNER_RADIUS, Size = new Vector2(BUTTON_WIDTH, bar_height), Masking = true, @@ -104,16 +111,16 @@ namespace osu.Game.Screens.SelectV2.Footer RelativeSizeAxes = Axes.Both, Width = 1f - mod_display_portion, Masking = true, - Child = MultiplierText = new OsuSpriteText + Child = multiplierText = new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Shear = -BUTTON_SHEAR, + Shear = -OsuGame.SHEAR, UseFullGlyphHeight = false, Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold) } }, - new Container + modContainer = new Container { CornerRadius = CORNER_RADIUS, RelativeSizeAxes = Axes.Both, @@ -126,23 +133,16 @@ namespace osu.Game.Screens.SelectV2.Footer Colour = colourProvider.Background3, RelativeSizeAxes = Axes.Both, }, - modDisplay = new ModDisplay(showExtendedInformation: false) + modDisplay = new ModDisplay(showExtendedInformation: true) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Shear = -BUTTON_SHEAR, + Shear = -OsuGame.SHEAR, Scale = new Vector2(0.5f), Current = { BindTarget = Current }, ExpansionMode = ExpansionMode.AlwaysContracted, }, - modCountText = new ModCountText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shear = -BUTTON_SHEAR, - Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), - Mods = { BindTarget = Current }, - } + overflowModCountDisplay = new ModCountText { Mods = { BindTarget = Current }, }, } }, } @@ -172,6 +172,18 @@ namespace osu.Game.Screens.SelectV2.Footer FinishTransforms(true); } + protected override bool OnMouseDown(MouseDownEvent e) + { + // should probably be OnClick but right mouse button clicks isn't setup well. + if (e.Button == MouseButton.Right) + { + RequestDeselectAllMods?.Invoke(); + return true; + } + + return base.OnMouseDown(e); + } + private const double duration = 240; private const Easing easing = Easing.OutQuint; @@ -182,7 +194,7 @@ namespace osu.Game.Screens.SelectV2.Footer modDisplayBar.MoveToY(20, duration, easing); modDisplayBar.FadeOut(duration, easing); modDisplay.FadeOut(duration, easing); - modCountText.FadeOut(duration, easing); + overflowModCountDisplay.FadeOut(duration, easing); unrankedBadge.MoveToY(20, duration, easing); unrankedBadge.FadeOut(duration, easing); @@ -192,14 +204,6 @@ namespace osu.Game.Screens.SelectV2.Footer } else { - modDisplay.Hide(); - modCountText.Hide(); - - if (Current.Value.Count >= 5) - modCountText.Show(); - else - modDisplay.Show(); - if (Current.Value.Any(m => !m.Ranked)) { unrankedBadge.MoveToX(0, duration, easing); @@ -218,44 +222,80 @@ namespace osu.Game.Screens.SelectV2.Footer modDisplayBar.MoveToY(-5, duration, Easing.OutQuint); unrankedBadge.MoveToY(-5, duration, easing); modDisplayBar.FadeIn(duration, easing); + modDisplay.FadeIn(duration, easing); } double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; - MultiplierText.Text = ModUtils.FormatScoreMultiplier(multiplier); + multiplierText.Text = ModUtils.FormatScoreMultiplier(multiplier); if (multiplier > 1) - MultiplierText.FadeColour(colours.Red1, duration, easing); + multiplierText.FadeColour(colours.Red1, duration, easing); else if (multiplier < 1) - MultiplierText.FadeColour(colours.Lime1, duration, easing); + multiplierText.FadeColour(colours.Lime1, duration, easing); else - MultiplierText.FadeColour(Color4.White, duration, easing); + multiplierText.FadeColour(Color4.White, duration, easing); } - private partial class ModCountText : OsuSpriteText, IHasCustomTooltip> + protected override void Update() + { + base.Update(); + + if (Current.Value.Count == 0) + return; + + if (modDisplay.DrawWidth * modDisplay.Scale.X > modContainer.DrawWidth) + overflowModCountDisplay.Show(); + else + overflowModCountDisplay.Hide(); + } + + private partial class ModCountText : CompositeDrawable, IHasCustomTooltip> { public readonly Bindable> Mods = new Bindable>(); + private OsuSpriteText text = null!; + [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; protected override void LoadComplete() { base.LoadComplete(); - Mods.BindValueChanged(v => Text = ModSelectOverlayStrings.Mods(v.NewValue.Count).ToUpper(), true); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + Alpha = 0.8f, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Shear = -OsuGame.SHEAR, + } + }; + + Mods.BindValueChanged(v => text.Text = ModSelectOverlayStrings.Mods(v.NewValue.Count).ToUpper(), true); } - public ITooltip> GetCustomTooltip() => new ModTooltip(colourProvider); + public ITooltip> GetCustomTooltip() => new ModOverflowTooltip(colourProvider); public IReadOnlyList? TooltipContent => Mods.Value; - public partial class ModTooltip : VisibilityContainer, ITooltip> + public partial class ModOverflowTooltip : VisibilityContainer, ITooltip> { private ModDisplay extendedModDisplay = null!; [Cached] private OverlayColourProvider colourProvider; - public ModTooltip(OverlayColourProvider colourProvider) + public ModOverflowTooltip(OverlayColourProvider colourProvider) { this.colourProvider = colourProvider; } @@ -305,7 +345,7 @@ namespace osu.Game.Screens.SelectV2.Footer Y = -5f; Depth = float.MaxValue; Origin = Anchor.BottomLeft; - Shear = BUTTON_SHEAR; + Shear = OsuGame.SHEAR; CornerRadius = CORNER_RADIUS; AutoSizeAxes = Axes.X; Height = bar_height; @@ -329,7 +369,7 @@ namespace osu.Game.Screens.SelectV2.Footer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Shear = -BUTTON_SHEAR, + Shear = -OsuGame.SHEAR, Text = ModSelectOverlayStrings.Unranked.ToUpper(), Margin = new MarginPadding { Horizontal = 15 }, UseFullGlyphHeight = false, diff --git a/osu.Game/Screens/SelectV2/FooterButtonOptions.Popover.cs b/osu.Game/Screens/SelectV2/FooterButtonOptions.Popover.cs new file mode 100644 index 0000000000..7e71fedfcb --- /dev/null +++ b/osu.Game/Screens/SelectV2/FooterButtonOptions.Popover.cs @@ -0,0 +1,204 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class FooterButtonOptions + { + public partial class Popover : OsuPopover + { + private FillFlowContainer buttonFlow = null!; + private readonly FooterButtonOptions footerButton; + + private readonly BeatmapInfo beatmap; + + // Can't use DI for these due to popover being initialised from a footer button which ends up being on the global + // PopoverContainer. + public ISongSelect? SongSelect { get; init; } + public required OverlayColourProvider ColourProvider { get; init; } + + public Popover(FooterButtonOptions footerButton, BeatmapInfo beatmap) + { + this.footerButton = footerButton; + this.beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Content.Padding = new MarginPadding(5); + + Child = buttonFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(3), + }; + + addHeader(CommonStrings.General); + addButton(CollectionsStrings.ManageCollections, FontAwesome.Solid.Book, () => SongSelect?.ManageCollections()); + + Debug.Assert(beatmap.BeatmapSet != null); + addHeader(SongSelectStrings.ForAllDifficulties, beatmap.BeatmapSet.ToString()); + addButton(SongSelectStrings.DeleteBeatmap, FontAwesome.Solid.Trash, () => SongSelect?.Delete(beatmap.BeatmapSet), colours.Red1); + + addHeader(SongSelectStrings.ForSelectedDifficulty, beatmap.DifficultyName); + + if (SongSelect == null) return; + + foreach (OsuMenuItem item in SongSelect.GetForwardActions(beatmap)) + { + // We can't display menus with child items here, so just ignore them. + if (item.Items.Any()) + continue; + + if (item is OsuMenuItemSpacer) + { + buttonFlow.Add(new Container + { + RelativeSizeAxes = Axes.X, + Height = 10, + }); + continue; + } + + addButton(item.Text.Value, item.Icon, item.Action.Value, item.Type == MenuItemType.Destructive ? colours.Red1 : null); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(this)); + } + + protected override void UpdateState(ValueChangedEvent state) + { + base.UpdateState(state); + footerButton.OverlayState.Value = state.NewValue; + } + + private void addHeader(LocalisableString text, string? context = null) + { + var textFlow = new OsuTextFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding(10), + }; + + textFlow.AddText(text, t => t.Font = OsuFont.Default.With(weight: FontWeight.SemiBold)); + + if (context != null) + { + textFlow.NewLine(); + textFlow.AddText(context, t => + { + t.Colour = ColourProvider.Content2; + t.Font = t.Font.With(size: 13); + }); + } + + buttonFlow.Add(textFlow); + } + + private void addButton(LocalisableString text, IconUsage? icon, Action? action, Color4? colour = null) + { + var button = new OptionButton + { + Text = text, + Icon = icon ?? new IconUsage(), + BackgroundColour = ColourProvider.Background3, + TextColour = colour, + Action = () => + { + Scheduler.AddDelayed(Hide, 50); + action?.Invoke(); + }, + }; + + buttonFlow.Add(button); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // don't absorb control as ToolbarRulesetSelector uses control + number to navigate + if (e.ControlPressed) return false; + + if (!e.Repeat && e.Key >= Key.Number1 && e.Key <= Key.Number9) + { + int requested = e.Key - Key.Number1; + + OptionButton? found = buttonFlow.Children.OfType().ElementAtOrDefault(requested); + + if (found != null) + { + found.TriggerClick(); + return true; + } + } + + return base.OnKeyDown(e); + } + + private partial class OptionButton : OsuButton + { + public IconUsage Icon { get; init; } + public Color4? TextColour { get; init; } + + public OptionButton() + { + Size = new Vector2(265, 50); + } + + [BackgroundDependencyLoader] + private void load() + { + SpriteText.Colour = TextColour ?? Color4.White; + Content.CornerRadius = 10; + + Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(17), + X = 15, + Icon = Icon, + Colour = TextColour ?? Color4.White, + }); + } + + protected override SpriteText CreateText() => new OsuSpriteText + { + Depth = -1, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + X = 40 + }; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/FooterButtonOptions.cs b/osu.Game/Screens/SelectV2/FooterButtonOptions.cs new file mode 100644 index 0000000000..4da40559e9 --- /dev/null +++ b/osu.Game/Screens/SelectV2/FooterButtonOptions.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.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Screens.Footer; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class FooterButtonOptions : ScreenFooterButton, IHasPopover + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private IBindable workingBeatmap { get; set; } = null!; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private Live beatmap = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + Text = SongSelectStrings.Options; + Icon = FontAwesome.Solid.Cog; + AccentColour = colour.Purple1; + Hotkey = GlobalAction.ToggleBeatmapOptions; + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + workingBeatmap.BindValueChanged(_ => beatmapChanged(), true); + } + + private void beatmapChanged() + { + this.HidePopover(); + Enabled.Value = !workingBeatmap.IsDefault; + if (!workingBeatmap.IsDefault) + beatmap = realm.Run(r => r.Find(workingBeatmap.Value.BeatmapInfo.ID)!.ToLive(realm)); + } + + public Framework.Graphics.UserInterface.Popover GetPopover() => new Popover(this, beatmap.Value.Detach()) + { + ColourProvider = colourProvider, + SongSelect = songSelect + }; + } +} diff --git a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonRandom.cs b/osu.Game/Screens/SelectV2/FooterButtonRandom.cs similarity index 94% rename from osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonRandom.cs rename to osu.Game/Screens/SelectV2/FooterButtonRandom.cs index dbdb6fe79b..4bd42497eb 100644 --- a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonRandom.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonRandom.cs @@ -10,13 +10,14 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Screens.Footer; using osuTK; using osuTK.Input; -namespace osu.Game.Screens.SelectV2.Footer +namespace osu.Game.Screens.SelectV2 { - public partial class ScreenFooterButtonRandom : ScreenFooterButton + public partial class FooterButtonRandom : ScreenFooterButton { public Action? NextRandom { get; set; } public Action? PreviousRandom { get; set; } @@ -46,7 +47,7 @@ namespace osu.Game.Screens.SelectV2.Footer AlwaysPresent = true, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "Random", + Text = SongSelectStrings.Random, }, rewindSpriteText = new OsuSpriteText { @@ -54,7 +55,7 @@ namespace osu.Game.Screens.SelectV2.Footer AlwaysPresent = true, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "Rewind", + Text = SongSelectStrings.Rewind, Alpha = 0f, } } @@ -121,7 +122,7 @@ namespace osu.Game.Screens.SelectV2.Footer protected override void OnMouseUp(MouseUpEvent e) { - if (e.Button == MouseButton.Right) + if (e.Button == MouseButton.Right && IsHovered) { rewindSearch = true; TriggerClick(); diff --git a/osu.Game/Screens/SelectV2/ISongSelect.cs b/osu.Game/Screens/SelectV2/ISongSelect.cs new file mode 100644 index 0000000000..e39f74c018 --- /dev/null +++ b/osu.Game/Screens/SelectV2/ISongSelect.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.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Scoring; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// Actions exposed by song select which are used by subcomponents to perform top-level operations. + /// + public interface ISongSelect + { + /// + /// Requests the user for confirmation to delete the given beatmap set. + /// + void Delete(BeatmapSetInfo beatmapBeatmapSetInfo); + + /// + /// Immediately restores any hidden beatmaps in the provided beatmap set. + /// + void RestoreAllHidden(BeatmapSetInfo beatmapSet); + + /// + /// Opens the manage collections dialog. + /// + void ManageCollections(); + + /// + /// Opens results screen with the given score. + /// This assumes active beatmap and ruleset selection matches the score. + /// + void PresentScore(ScoreInfo score); + + /// + /// Set the current filter text query to the provided string. + /// + void Search(string query); + + /// + /// Gets relevant actionable items for beatmap context menus, based on the type of song select. + /// + IEnumerable GetForwardActions(BeatmapInfo beatmap); + } +} diff --git a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs deleted file mode 100644 index 732fb2cd8c..0000000000 --- a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs +++ /dev/null @@ -1,792 +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.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Framework.Platform; -using osu.Game.Configuration; -using osu.Game.Extensions; -using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Leaderboards; -using osu.Game.Overlays; -using osu.Game.Resources.Localisation.Web; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; -using osu.Game.Scoring; -using osu.Game.Screens.Select; -using osu.Game.Users; -using osu.Game.Users.Drawables; -using osu.Game.Utils; -using osuTK; -using osuTK.Graphics; -using CommonStrings = osu.Game.Localisation.CommonStrings; - -namespace osu.Game.Screens.SelectV2.Leaderboards -{ - public partial class LeaderboardScoreV2 : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip - { - public Bindable> SelectedMods = new Bindable>(); - - /// - /// A function determining whether each mod in the score can be selected. - /// A return value of means that the mod can be selected in the current context. - /// A return value of means that the mod cannot be selected in the current context. - /// - public Func IsValidMod { get; set; } = _ => true; - - public int? Rank { get; init; } - public bool IsPersonalBest { get; init; } - - private const float expanded_right_content_width = 210; - private const float grade_width = 40; - private const float username_min_width = 125; - private const float statistics_regular_min_width = 175; - private const float statistics_compact_min_width = 100; - private const float rank_label_width = 65; - - private readonly ScoreInfo score; - private readonly bool sheared; - - private const int height = 60; - private const int corner_radius = 10; - private const int transition_duration = 200; - - private Colour4 foregroundColour; - private Colour4 backgroundColour; - private ColourInfo totalScoreBackgroundGradient; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - [Resolved] - private IDialogOverlay? dialogOverlay { get; set; } - - [Resolved] - private ScoreManager scoreManager { get; set; } = null!; - - [Resolved] - private Clipboard? clipboard { get; set; } - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - private Container content = null!; - private Box background = null!; - private Box foreground = null!; - - private Drawable avatar = null!; - private ClickableAvatar innerAvatar = null!; - - private OsuSpriteText nameLabel = null!; - private List statisticsLabels = null!; - - private Container rightContent = null!; - - protected Container RankContainer { get; private set; } = null!; - private FillFlowContainer flagBadgeAndDateContainer = null!; - private FillFlowContainer modsContainer = null!; - - private OsuSpriteText scoreText = null!; - private Drawable scoreRank = null!; - private Box totalScoreBackground = null!; - - private FillFlowContainer statisticsContainer = null!; - private RankLabel rankLabel = null!; - private Container rankLabelOverlay = null!; - - public ITooltip GetCustomTooltip() => new LeaderboardScoreTooltip(); - public virtual ScoreInfo TooltipContent => score; - - public LeaderboardScoreV2(ScoreInfo score, bool sheared = true) - { - this.score = score; - this.sheared = sheared; - - Shear = new Vector2(sheared ? OsuGame.SHEAR : 0, 0); - RelativeSizeAxes = Axes.X; - Height = height; - } - - [BackgroundDependencyLoader] - private void load() - { - var user = score.User; - - foregroundColour = IsPersonalBest ? colourProvider.Background1 : colourProvider.Background5; - backgroundColour = IsPersonalBest ? colourProvider.Background2 : colourProvider.Background4; - totalScoreBackgroundGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), backgroundColour); - - statisticsLabels = GetStatistics(score).Select(s => new ScoreComponentLabel(s, score) - { - // ensure statistics container is the correct width when invalidating - AlwaysPresent = true, - }).ToList(); - - Child = content = new Container - { - Masking = true, - CornerRadius = corner_radius, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = backgroundColour - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Child = rankLabel = new RankLabel(Rank, sheared) - { - Width = rank_label_width, - RelativeSizeAxes = Axes.Y, - }, - }, - createCentreContent(user), - createRightContent() - } - } - } - } - }; - - innerAvatar.OnLoadComplete += d => d.FadeInFromZero(200); - } - - [Resolved] - private OsuConfigManager config { get; set; } = null!; - - private IBindable scoringMode { get; set; } = null!; - - protected override void LoadComplete() - { - base.LoadComplete(); - - scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); - scoringMode.BindValueChanged(s => - { - switch (s.NewValue) - { - case ScoringMode.Standardised: - rightContent.Width = 180f; - break; - - case ScoringMode.Classic: - rightContent.Width = expanded_right_content_width; - break; - } - - updateModDisplay(); - }, true); - } - - private void updateModDisplay() - { - int maxMods = scoringMode.Value == ScoringMode.Standardised ? 4 : 5; - - if (score.Mods.Length > 0) - { - modsContainer.Padding = new MarginPadding { Top = 4f }; - modsContainer.ChildrenEnumerable = score.Mods.AsOrdered().Take(Math.Min(maxMods, score.Mods.Length)).Select(mod => new ColouredModSwitchTiny(mod) - { - Scale = new Vector2(0.375f) - }); - - if (score.Mods.Length > maxMods) - { - modsContainer.Remove(modsContainer[^1], true); - modsContainer.Add(new MoreModSwitchTiny(score.Mods.Length - maxMods + 1) - { - Scale = new Vector2(0.375f), - }); - } - } - } - - private Container createCentreContent(APIUser user) => new Container - { - Name = @"Centre container", - Masking = true, - CornerRadius = corner_radius, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - foreground = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = foregroundColour - }, - new UserCoverBackground - { - RelativeSizeAxes = Axes.Both, - User = score.User, - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Colour = ColourInfo.GradientHorizontal(Colour4.White.Opacity(0.5f), Colour4.FromHex(@"222A27").Opacity(1)), - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - new Container - { - AutoSizeAxes = Axes.Both, - CornerRadius = corner_radius, - Masking = true, - Children = new[] - { - avatar = new DelayedLoadWrapper( - innerAvatar = new ClickableAvatar(user) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(1.1f), - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - RelativeSizeAxes = Axes.Both, - }) - { - RelativeSizeAxes = Axes.None, - Size = new Vector2(height) - }, - rankLabelOverlay = new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.Black.Opacity(0.5f), - }, - new RankLabel(Rank, sheared) - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - } - } - }, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = corner_radius }, - Children = new Drawable[] - { - flagBadgeAndDateContainer = new FillFlowContainer - { - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - AutoSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - new UpdateableFlag(user.CountryCode) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(24, 16), - }, - new DateLabel(score.Date) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - UseFullGlyphHeight = false, - } - } - }, - nameLabel = new TruncatingSpriteText - { - RelativeSizeAxes = Axes.X, - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - Text = user.Username, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold) - } - } - }, - new Container - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Child = statisticsContainer = new FillFlowContainer - { - Name = @"Statistics container", - Padding = new MarginPadding { Right = 40 }, - Spacing = new Vector2(25, 0), - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = statisticsLabels, - Alpha = 0, - LayoutEasing = Easing.OutQuint, - LayoutDuration = transition_duration, - } - } - } - }, - }, - }, - }; - - private Container createRightContent() => rightContent = new Container - { - Name = @"Right content", - RelativeSizeAxes = Axes.Y, - Child = new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = grade_width }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank)), - }, - }, - new Box - { - RelativeSizeAxes = Axes.Y, - Width = grade_width, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = OsuColour.ForRank(score.Rank), - }, - new TrianglesV2 - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - SpawnRatio = 2, - Velocity = 0.7f, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Darken(0.2f)), - }, - RankContainer = new Container - { - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Y, - Width = grade_width, - Child = scoreRank = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Spacing = new Vector2(-2), - Colour = DrawableRank.GetRankNameColour(score.Rank), - Font = OsuFont.Numeric.With(size: 16), - Text = DrawableRank.GetRankName(score.Rank), - ShadowColour = Color4.Black.Opacity(0.3f), - ShadowOffset = new Vector2(0, 0.08f), - Shadow = true, - UseFullGlyphHeight = false, - }, - }, - new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Right = grade_width }, - Child = new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Masking = true, - CornerRadius = corner_radius, - Children = new Drawable[] - { - totalScoreBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = totalScoreBackgroundGradient, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Opacity(0.5f)), - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = corner_radius }, - Children = new Drawable[] - { - scoreText = new OsuSpriteText - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - UseFullGlyphHeight = false, - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - Current = scoreManager.GetBindableTotalScoreString(score), - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Light), - }, - modsContainer = new FillFlowContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(2f, 0f), - }, - } - } - } - } - } - } - }, - }; - - protected (CaseTransformableString, LocalisableString DisplayAccuracy)[] GetStatistics(ScoreInfo model) => new[] - { - (BeatmapsetsStrings.ShowScoreboardHeadersCombo.ToUpper(), model.MaxCombo.ToString().Insert(model.MaxCombo.ToString().Length, "x")), - (BeatmapsetsStrings.ShowScoreboardHeadersAccuracy.ToUpper(), model.DisplayAccuracy), - }; - - public override void Show() - { - foreach (var d in new[] { avatar, nameLabel, scoreText, scoreRank, flagBadgeAndDateContainer, modsContainer }.Concat(statisticsLabels)) - d.FadeOut(); - - Alpha = 0; - - content.MoveToY(75); - avatar.MoveToX(75); - nameLabel.MoveToX(150); - - this.FadeIn(200); - content.MoveToY(0, 800, Easing.OutQuint); - - using (BeginDelayedSequence(100)) - { - avatar.FadeIn(300, Easing.OutQuint); - nameLabel.FadeIn(350, Easing.OutQuint); - - avatar.MoveToX(0, 300, Easing.OutQuint); - nameLabel.MoveToX(0, 350, Easing.OutQuint); - - using (BeginDelayedSequence(250)) - { - scoreText.FadeIn(200); - scoreRank.FadeIn(200); - - using (BeginDelayedSequence(50)) - { - var drawables = new Drawable[] { flagBadgeAndDateContainer, modsContainer }.Concat(statisticsLabels).ToArray(); - for (int i = 0; i < drawables.Length; i++) - drawables[i].FadeIn(100 + i * 50); - } - } - } - } - - protected override bool OnHover(HoverEvent e) - { - updateState(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateState(); - base.OnHoverLost(e); - } - - private void updateState() - { - var lightenedGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0).Lighten(0.2f), backgroundColour.Lighten(0.2f)); - - foreground.FadeColour(IsHovered ? foregroundColour.Lighten(0.2f) : foregroundColour, transition_duration, Easing.OutQuint); - background.FadeColour(IsHovered ? backgroundColour.Lighten(0.2f) : backgroundColour, transition_duration, Easing.OutQuint); - totalScoreBackground.FadeColour(IsHovered ? lightenedGradient : totalScoreBackgroundGradient, transition_duration, Easing.OutQuint); - - if (IsHovered && currentMode != DisplayMode.Full) - rankLabelOverlay.FadeIn(transition_duration, Easing.OutQuint); - else - rankLabelOverlay.FadeOut(transition_duration, Easing.OutQuint); - } - - private DisplayMode? currentMode; - - protected override void Update() - { - base.Update(); - - DisplayMode mode = getCurrentDisplayMode(); - - if (currentMode != mode) - { - if (mode >= DisplayMode.Full) - rankLabel.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); - else - rankLabel.FadeOut(transition_duration, Easing.OutQuint).MoveToX(-rankLabel.DrawWidth, transition_duration, Easing.OutQuint); - - if (mode >= DisplayMode.Regular) - { - statisticsContainer.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); - statisticsContainer.Direction = FillDirection.Horizontal; - statisticsContainer.ScaleTo(1, transition_duration, Easing.OutQuint); - } - else if (mode >= DisplayMode.Compact) - { - statisticsContainer.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); - statisticsContainer.Direction = FillDirection.Vertical; - statisticsContainer.ScaleTo(0.8f, transition_duration, Easing.OutQuint); - } - else - statisticsContainer.FadeOut(transition_duration, Easing.OutQuint).MoveToX(statisticsContainer.DrawWidth, transition_duration, Easing.OutQuint); - - currentMode = mode; - } - } - - private DisplayMode getCurrentDisplayMode() - { - if (DrawWidth >= height + username_min_width + statistics_regular_min_width + expanded_right_content_width + rank_label_width) - return DisplayMode.Full; - - if (DrawWidth >= height + username_min_width + statistics_regular_min_width + expanded_right_content_width) - return DisplayMode.Regular; - - if (DrawWidth >= height + username_min_width + statistics_compact_min_width + expanded_right_content_width) - return DisplayMode.Compact; - - return DisplayMode.Minimal; - } - - #region Subclasses - - private enum DisplayMode - { - Minimal, - Compact, - Regular, - Full - } - - private partial class DateLabel : DrawableDate - { - public DateLabel(DateTimeOffset date) - : base(date) - { - Font = OsuFont.GetFont(size: 16, weight: FontWeight.Medium, italics: true); - } - - protected override string Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); - } - - private partial class ScoreComponentLabel : Container - { - private readonly (LocalisableString Name, LocalisableString Value) statisticInfo; - private readonly ScoreInfo score; - - private FillFlowContainer content = null!; - public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); - - public ScoreComponentLabel((LocalisableString Name, LocalisableString Value) statisticInfo, ScoreInfo score) - { - this.statisticInfo = statisticInfo; - this.score = score; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours, OverlayColourProvider colourProvider) - { - AutoSizeAxes = Axes.Both; - OsuSpriteText value; - Child = content = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OsuSpriteText - { - Colour = colourProvider.Content2, - Text = statisticInfo.Name, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - }, - value = new OsuSpriteText - { - // We don't want the value setting the horizontal size, since it leads to wonky accuracy container length, - // since the accuracy is sometimes longer than its name. - BypassAutoSizeAxes = Axes.X, - Text = statisticInfo.Value, - Font = OsuFont.GetFont(size: 19, weight: FontWeight.Medium), - } - } - }; - - if (score.Combo != score.MaxCombo && statisticInfo.Name == BeatmapsetsStrings.ShowScoreboardHeadersCombo) - value.Colour = colours.Lime1; - } - } - - private partial class RankLabel : Container, IHasTooltip - { - public RankLabel(int? rank, bool sheared) - { - if (rank >= 1000) - TooltipText = $"#{rank:N0}"; - - Child = new OsuSpriteText - { - Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold, italics: true), - Text = rank == null ? "-" : rank.Value.FormatRank().Insert(0, "#") - }; - } - - public LocalisableString TooltipText { get; } - } - - private sealed partial class ColouredModSwitchTiny : ModSwitchTiny, IHasTooltip - { - private readonly IMod mod; - - public ColouredModSwitchTiny(IMod mod) - : base(mod) - { - this.mod = mod; - Active.Value = true; - } - - public LocalisableString TooltipText => (mod as Mod)?.IconTooltip ?? mod.Name; - } - - private sealed partial class MoreModSwitchTiny : CompositeDrawable - { - private readonly int count; - - public MoreModSwitchTiny(int count) - { - this.count = count; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Size = new Vector2(ModSwitchTiny.WIDTH, ModSwitchTiny.DEFAULT_HEIGHT); - - InternalChild = new CircularContainer - { - Masking = true, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.2f), - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shadow = false, - Font = OsuFont.Numeric.With(size: 24, weight: FontWeight.Black), - Text = $"+{count}", - Colour = colours.Yellow, - Margin = new MarginPadding - { - Top = 4 - } - } - } - }; - } - } - - #endregion - - public MenuItem[] ContextMenuItems - { - get - { - List items = new List(); - - if (score.Mods.Length > 0) - items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = score.Mods.Where(m => IsValidMod.Invoke(m)).ToArray())); - - if (score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{score.OnlineID}"))); - - if (score.Files.Count <= 0) return items.ToArray(); - - items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(score))); - items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); - - return items.ToArray(); - } - } - } -} diff --git a/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs b/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs new file mode 100644 index 0000000000..597b6de851 --- /dev/null +++ b/osu.Game/Screens/SelectV2/NoResultsPlaceholder.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class NoResultsPlaceholder : VisibilityContainer + { + public Action? RequestClearFilterText { get; init; } + + private FilterCriteria? filter; + + private LinkFlowContainer textFlow = null!; + + private GhostIcon icon = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private FirstRunSetupOverlay? firstRunSetupOverlay { get; set; } + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + protected override bool StartHidden => true; + + public FilterCriteria Filter + { + set + { + if (filter == value) + return; + + filter = value; + Scheduler.AddOnce(updateText); + } + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(10), + Size = new Vector2(50), + Child = icon = new GhostIcon + { + RelativeSizeAxes = Axes.Both, + }, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Title, + Text = SongSelectStrings.NoMatchingBeatmaps + }, + textFlow = new LinkFlowContainer + { + Alpha = 0, + AlwaysPresent = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Padding = new MarginPadding { Top = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + icon.Loop(t => + t.MoveToY(-10, 2000, Easing.InOutSine) + .Then() + .MoveToY(0, 2000, Easing.InOutSine) + ); + } + + protected override void PopIn() + { + this.FadeIn(600, Easing.OutQuint); + + Scheduler.AddOnce(updateText); + } + + protected override void PopOut() + { + this.FadeOut(200, Easing.OutQuint); + } + + private void updateText() + { + // TODO: Refresh this text when new beatmaps are imported. Right now it won't get up-to-date suggestions. + + // Bounce should play every time the filter criteria is updated. + this.ScaleTo(0.9f) + .ScaleTo(1f, 1000, Easing.OutQuint); + + textFlow.FadeInFromZero(800, Easing.OutQuint); + + textFlow.Clear(); + + if (beatmaps.QueryBeatmapSet(s => !s.Protected && !s.DeletePending) == null) + { + addBulletPoint(); + textFlow.AddText("Consider running the \""); + textFlow.AddLink(FirstRunSetupOverlayStrings.FirstRunSetupTitle, () => firstRunSetupOverlay?.Show()); + textFlow.AddText("\" to download or import some beatmaps!"); + } + else + { + textFlow.AddParagraph(SongSelectStrings.NoMatchingBeatmapsDescription); + textFlow.AddParagraph(string.Empty); + + if (!string.IsNullOrEmpty(filter?.SearchText)) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("clearing", () => + { + RequestClearFilterText?.Invoke(); + }); + + textFlow.AddText(" your current search criteria."); + } + + if (filter?.UserStarDifficulty.HasFilter == true) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("removing", () => + { + config.SetValue(OsuSetting.DisplayStarsMinimum, 0.0); + config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1); + }); + + string lowerStar = $"{filter.UserStarDifficulty.Min ?? 0:N1}"; + string upperStar = filter.UserStarDifficulty.Max == null ? "∞" : $"{filter.UserStarDifficulty.Max:N1}"; + + textFlow.AddText($" the {lowerStar} - {upperStar} star difficulty filter."); + } + + // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). + // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. + if (filter?.Ruleset?.OnlineID != 0 && filter?.AllowConvertedBeatmaps == false) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("enabling", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + textFlow.AddText(" automatic conversion!"); + } + } + + if (!string.IsNullOrEmpty(filter?.SearchText)) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); + textFlow.AddText($" for \"{filter.SearchText}\"."); + } + // TODO: add clickable link to reset criteria. + } + + private void addBulletPoint() + { + textFlow.NewLine(); + textFlow.AddIcon(FontAwesome.Solid.Circle, i => + { + i.Padding = new MarginPadding { Top = 24, Right = 15 }; + i.Scale *= 0.3f; + }); + } + } +} diff --git a/osu.Game/Screens/SelectV2/Panel.cs b/osu.Game/Screens/SelectV2/Panel.cs new file mode 100644 index 0000000000..241002fa76 --- /dev/null +++ b/osu.Game/Screens/SelectV2/Panel.cs @@ -0,0 +1,392 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.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.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public abstract partial class Panel : PoolableDrawable, ICarouselPanel, IHasContextMenu + { + public const float CORNER_RADIUS = 10; + + private const float active_x_offset = 25f; + + protected const float DURATION = 400; + + protected float PanelXOffset { get; init; } + + private Container backgroundContainer = null!; + private Container iconContainer = null!; + + private Drawable activationFlash = null!; + private Drawable hoverLayer = null!; + + private Drawable keyboardSelectionLayer = null!; + + private PulsatingBox selectionLayer = null!; + + public Container TopLevelContent { get; private set; } = null!; + + private Container contentPaddingContainer = null!; + protected Container Content { get; private set; } = null!; + + public Drawable Background + { + set => backgroundContainer.Child = value; + } + + public Drawable Icon + { + set => iconContainer.Child = value; + } + + private Color4? accentColour; + + public Color4? AccentColour + { + get => accentColour; + set + { + if (value == accentColour) + return; + + accentColour = value; + updateAccentColour(); + } + } + + public sealed override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (item == null) + return TopLevelContent.ReceivePositionalInputAt(screenSpacePos); + + var inputRectangle = TopLevelContent.DrawRectangle; + + // Cover the gaps introduced by the spacing between panels so that user mis-aims don't result in no-ops. + inputRectangle = inputRectangle.Inflate(new MarginPadding + { + Top = item.CarouselInputLenienceAbove, + Bottom = item.CarouselInputLenienceBelow, + }); + + return inputRectangle.Contains(TopLevelContent.ToLocalSpace(screenSpacePos)); + } + + [Resolved] + private BeatmapCarousel? carousel { get; set; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + RelativeSizeAxes = Axes.X; + Height = CarouselItem.DEFAULT_HEIGHT; + + InternalChild = TopLevelContent = new Container + { + Masking = true, + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + X = CORNER_RADIUS, + Children = new[] + { + backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + iconContainer = new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + }, + contentPaddingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = Content = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = CORNER_RADIUS, + Masking = true, + }, + }, + hoverLayer = new Box + { + Alpha = 0, + Colour = colours.Blue.Opacity(0.1f), + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + }, + selectionLayer = new PulsatingBox + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + Blending = BlendingParameters.Additive, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + keyboardSelectionLayer = new Box + { + Alpha = 0, + Colour = ColourInfo.GradientHorizontal(colourProvider.Highlight1.Opacity(0.1f), colourProvider.Highlight1.Opacity(0.4f)), + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + }, + activationFlash = new Box + { + Colour = Color4.White.Opacity(0.4f), + Blending = BlendingParameters.Additive, + Alpha = 0f, + RelativeSizeAxes = Axes.Both, + }, + new HoverSounds(), + } + }; + } + + public partial class PulsatingBox : BeatSyncedContainer + { + public int FlashOffset; + + private readonly Box box; + + public PulsatingBox() + { + EarlyActivationMilliseconds = 40; + + InternalChildren = new Drawable[] + { + box = new Box + { + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + if (beatIndex % Math.Pow(2, FlashOffset) != 0) + return; + + double length = timingPoint.BeatLength; + + while (length < 250) + length *= 2; + + box + .FadeTo(0.8f, 40, Easing.Out) + .Then() + .FadeTo(0.4f, length, Easing.Out); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => + { + updateSelectedState(); + updateXOffset(); + }); + + Selected.BindValueChanged(_ => + { + updateSelectedState(); + updateXOffset(); + }, true); + + KeyboardSelected.BindValueChanged(selected => + { + if (selected.NewValue) + { + keyboardSelectionLayer.FadeIn(80, Easing.Out) + .Then() + .FadeTo(0.5f, 2000, Easing.OutQuint); + } + else + keyboardSelectionLayer.FadeOut(1000, Easing.OutQuint); + + updateXOffset(); + }, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + // Slightly offset the flash animation based on the panel depth. + // This assumes a minimum depth of -2 (groups). + selectionLayer.FlashOffset = -Item!.DepthLayer; + + updateAccentColour(); + + updateXOffset(animated: false); + updateSelectedState(animated: false); + + this.FadeIn(DURATION, Easing.OutQuint); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + Hide(); + + // Important to set this to null to handle reuse scenarios correctly, see `Item` implementation. + item = null; + } + + protected override bool OnClick(ClickEvent e) + { + carousel?.Activate(Item!); + return true; + } + + private void updateAccentColour() + { + var backgroundColour = accentColour ?? Color4.White; + + selectionLayer.Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), backgroundColour.Opacity(0.5f)); + + updateSelectedState(animated: false); + } + + private void updateSelectedState(bool animated = true) + { + bool selectedOrExpanded = Expanded.Value || Selected.Value; + + var edgeEffectColour = accentColour ?? Color4Extensions.FromHex(@"4EBFFF"); + + if (selectedOrExpanded) + { + TopLevelContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 2f, + Hollow = true, + }; + } + else + { + TopLevelContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 4f, + Hollow = true, + Offset = new Vector2(0f, 1f), + }; + } + + TopLevelContent.FadeEdgeEffectTo(selectedOrExpanded ? edgeEffectColour.Opacity(0.8f) : Color4.Black.Opacity(0.2f), animated ? DURATION : 0, Easing.OutQuint); + + if (selectedOrExpanded) + selectionLayer.FadeIn(100, Easing.OutQuint); + else + selectionLayer.FadeOut(200, Easing.OutQuint); + } + + private void updateXOffset(bool animated = true) + { + float x = PanelXOffset + CORNER_RADIUS; + + if (!Expanded.Value && !Selected.Value) + { + if (this is PanelBeatmap || this is PanelBeatmapStandalone) + x += active_x_offset * 2; + else + x += active_x_offset * 4; + } + + if (!KeyboardSelected.Value) + x += active_x_offset; + + TopLevelContent.MoveToX(x, animated ? DURATION : 0, Easing.OutQuint); + } + + protected override bool OnHover(HoverEvent e) + { + hoverLayer.FadeIn(100, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverLayer.FadeOut(1000, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override void Update() + { + base.Update(); + contentPaddingContainer.Padding = contentPaddingContainer.Padding with { Left = iconContainer.DrawWidth }; + } + + public abstract MenuItem[]? ContextMenuItems { get; } + + #region ICarouselPanel + + private CarouselItem? item; + + public CarouselItem? Item + { + get => item; + set + { + if (ReferenceEquals(item, value)) + return; + + // If a new item is set and we already have an item, this is a case of reuse. + // To keep things simple, assume that we need to do a full refresh. + // + // In the future, this could be more contextual and check whether the associated model has actually changed. + if (item != null && value != null) + { + item = value; + PrepareForUse(); + } + else + item = value; + } + } + + public BindableBool Selected { get; } = new BindableBool(); + public BindableBool Expanded { get; } = new BindableBool(); + public BindableBool KeyboardSelected { get; } = new BindableBool(); + + public double DrawYPosition { get; set; } + + public virtual void Activated() + { + activationFlash.FadeOutFromOne(1000, Easing.OutQuint); + } + + #endregion + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs new file mode 100644 index 0000000000..59603e145d --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -0,0 +1,322 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelBeatmap : Panel + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT; + + private StarCounter starCounter = null!; + private ConstrainedIconContainer difficultyIcon = null!; + private OsuSpriteText keyCountText = null!; + private StarRatingDisplay starRatingDisplay = null!; + private PanelLocalRankDisplay localRank = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText authorText = null!; + private FillFlowContainer mainFill = null!; + + private IBindable? starDifficultyBindable; + private CancellationTokenSource? starDifficultyCancellationSource; + + private Box backgroundBorder = null!; + private Box backgroundDifficultyTint = null!; + + private TrianglesV2 triangles = null!; + + [Resolved] + private IRulesetStore rulesets { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + private BeatmapInfo beatmap => ((GroupedBeatmap)Item!.Model).Beatmap; + + public PanelBeatmap() + { + PanelXOffset = 60; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = HEIGHT; + + Icon = difficultyIcon = new ConstrainedIconContainer + { + Size = new Vector2(9f), + Margin = new MarginPadding { Left = 2.5f, Right = 1.5f }, + Colour = colourProvider.Background5, + }; + + Background = backgroundBorder = new Box + { + RelativeSizeAxes = Axes.Both, + }; + + Content.Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background3, colourProvider.Background4), + }, + backgroundDifficultyTint = new Box + { + RelativeSizeAxes = Axes.Both, + }, + triangles = new TrianglesV2 + { + ScaleAdjust = 1.2f, + Thickness = 0.01f, + Velocity = 0.3f, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Spacing = new Vector2(5), + Margin = new MarginPadding { Left = 6.5f }, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + localRank = new PanelLocalRankDisplay + { + Scale = new Vector2(0.8f), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + }, + mainFill = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 3.5f }, + Children = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 4 }, + Children = new Drawable[] + { + keyCountText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Alpha = 0, + }, + difficultyText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 3f }, + }, + authorText = new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft + } + } + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small, animated: true) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.875f), + }, + starCounter = new StarCounter + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.4f) + } + }, + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => updateKeyCount()); + mods.BindValueChanged(_ => updateKeyCount(), true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + difficultyIcon.Icon = getRulesetIcon(beatmap.Ruleset); + + localRank.Beatmap = beatmap; + difficultyText.Text = beatmap.DifficultyName; + authorText.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); + + computeStarRating(); + updateKeyCount(); + } + + private Drawable getRulesetIcon(RulesetInfo rulesetInfo) + { + var rulesetInstance = rulesets.GetRuleset(rulesetInfo.ShortName)?.CreateInstance(); + + if (rulesetInstance is null) + return new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; + + return rulesetInstance.CreateIcon(); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + localRank.Beatmap = null; + starDifficultyBindable = null; + + starDifficultyCancellationSource?.Cancel(); + } + + private void computeStarRating() + { + starDifficultyCancellationSource?.Cancel(); + starDifficultyCancellationSource = new CancellationTokenSource(); + + if (Item == null) + return; + + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE); + starDifficultyBindable.BindValueChanged(starDifficulty => + { + starRatingDisplay.Current.Value = starDifficulty.NewValue; + starCounter.Current = (float)starDifficulty.NewValue.Stars; + }, true); + } + + protected override void Update() + { + base.Update(); + + if (Item?.IsVisible != true) + { + starDifficultyCancellationSource?.Cancel(); + starDifficultyCancellationSource = null; + } + + // Dirty hack to make sure we don't take up spacing in parent fill flow when not displaying a rank. + // I can't find a better way to do this. + mainFill.Margin = new MarginPadding { Left = 1 / starRatingDisplay.Scale.X * (localRank.HasRank ? 0 : -3) }; + + var diffColour = starRatingDisplay.DisplayedDifficultyColour; + + if (AccentColour != diffColour) + { + AccentColour = diffColour; + starCounter.Colour = diffColour; + + backgroundBorder.Colour = diffColour; + backgroundDifficultyTint.Colour = ColourInfo.GradientHorizontal(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); + + difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; + + triangles.Colour = ColourInfo.GradientVertical(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); + } + } + + private void updateKeyCount() + { + if (Item == null) + return; + + if (ruleset.Value.OnlineID == 3) + { + // Account for mania differences locally for now. + // Eventually this should be handled in a more modular way, allowing rulesets to add more information to the panel. + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + int keyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); + + keyCountText.Alpha = 1; + keyCountText.Text = $"[{keyCount}K] "; + } + else + keyCountText.Alpha = 0; + } + + public override MenuItem[] ContextMenuItems + { + get + { + if (Item == null) + return Array.Empty(); + + List items = new List(); + + if (songSelect != null) + items.AddRange(songSelect.GetForwardActions(beatmap)); + + return items.ToArray(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs new file mode 100644 index 0000000000..91645d261c --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs @@ -0,0 +1,325 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using 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.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osuTK; +using osuTK.Graphics; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelBeatmapSet : Panel + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f; + + private Box chevronBackground = null!; + private PanelSetBackground setBackground = null!; + private ScheduledDelegate? scheduledBackgroundRetrieval; + + private OsuSpriteText titleText = null!; + private OsuSpriteText artistText = null!; + private Drawable chevronIcon = null!; + private PanelUpdateBeatmapButton updateButton = null!; + private BeatmapSetOnlineStatusPill statusPill = null!; + private DifficultySpectrumDisplay difficultiesDisplay = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private BeatmapSetOverlay? beatmapOverlay { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + private GroupedBeatmapSet groupedBeatmapSet + { + get + { + Debug.Assert(Item != null); + return (GroupedBeatmapSet)Item!.Model; + } + } + + public PanelBeatmapSet() + { + PanelXOffset = 20f; + } + + [BackgroundDependencyLoader] + private void load() + { + Height = HEIGHT; + + Icon = chevronIcon = new Container + { + Size = new Vector2(0, 22), + Child = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(8), + X = 1f, + Colour = colourProvider.Background5, + }, + }; + + Background = chevronBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Alpha = 0f, + }; + + Content.Children = new Drawable[] + { + setBackground = new PanelSetBackground(), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Top = 7.5f, Left = 15, Bottom = 13 }, + Children = new Drawable[] + { + titleText = new OsuSpriteText + { + Font = OsuFont.Style.Heading1.With(typeface: Typeface.TorusAlternate), + }, + artistText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 4f }, + Children = new Drawable[] + { + statusPill = new BeatmapSetOnlineStatusPill + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + TextSize = OsuFont.Style.Caption2.Size, + Margin = new MarginPadding { Right = 5f }, + Animated = false, + }, + updateButton = new PanelUpdateBeatmapButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 5f, Top = -2f }, + }, + difficultiesDisplay = new DifficultySpectrumDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + }, + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + KeyboardSelected.BindValueChanged(k => KeyboardSelected.Value = k.NewValue, true); + } + + private void onExpanded() + { + if (Expanded.Value) + { + chevronBackground.FadeIn(DURATION / 2, Easing.OutQuint); + chevronIcon.ResizeWidthTo(18, DURATION * 1.5f, Easing.OutElasticQuarter); + chevronIcon.FadeTo(1f, DURATION, Easing.OutQuint); + } + else + { + chevronBackground.FadeOut(DURATION, Easing.OutQuint); + chevronIcon.ResizeWidthTo(0f, DURATION, Easing.OutQuint); + chevronIcon.FadeTo(0f, DURATION, Easing.OutQuint); + } + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + var beatmapSet = groupedBeatmapSet.BeatmapSet; + + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). + scheduledBackgroundRetrieval = Scheduler.AddDelayed(s => setBackground.Beatmap = beatmaps.GetWorkingBeatmap(s.Beatmaps.MinBy(b => b.OnlineID)), beatmapSet, 50); + + titleText.Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title); + artistText.Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist); + updateButton.BeatmapSet = beatmapSet; + statusPill.Status = beatmapSet.Status; + difficultiesDisplay.BeatmapSet = beatmapSet; + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + scheduledBackgroundRetrieval?.Cancel(); + scheduledBackgroundRetrieval = null; + setBackground.Beatmap = null; + updateButton.BeatmapSet = null; + difficultiesDisplay.BeatmapSet = null; + } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + public override MenuItem[] ContextMenuItems + { + get + { + if (Item == null) + return Array.Empty(); + + var beatmapSet = groupedBeatmapSet.BeatmapSet; + + List items = new List(); + + if (Expanded.Value) + { + if (songSelect is SoloSongSelect soloSongSelect) + { + // Assume the current set has one of its beatmaps selected since it is expanded. + items.Add(new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => soloSongSelect.Edit(soloSongSelect.Beatmap.Value.BeatmapInfo)) + { + Icon = FontAwesome.Solid.PencilAlt + }); + items.Add(new OsuMenuItemSpacer()); + } + } + else + { + items.Add(new OsuMenuItem(WebCommonStrings.ButtonsExpand.ToSentence(), MenuItemType.Highlighted, () => TriggerClick())); + items.Add(new OsuMenuItemSpacer()); + } + + if (beatmapSet.OnlineID > 0) + { + items.Add(new OsuMenuItem(CommonStrings.Details, MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmapSet(beatmapSet.OnlineID))); + + if (beatmapSet.GetOnlineURL(api, ruleset.Value) is string url) + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url))); + + items.Add(new OsuMenuItemSpacer()); + } + + var collectionItems = realm.Realm.All() + .OrderBy(c => c.Name) + .AsEnumerable() + .Select(createCollectionMenuItem) + .ToList(); + + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem(CommonStrings.Manage, MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem(CommonStrings.Collections) { Items = collectionItems }); + + if (beatmapSet.Beatmaps.Any(b => b.Hidden)) + items.Add(new OsuMenuItem(SongSelectStrings.RestoreAllHidden, MenuItemType.Standard, () => songSelect?.RestoreAllHidden(beatmapSet))); + + items.Add(new OsuMenuItem(SongSelectStrings.DeleteBeatmap, MenuItemType.Destructive, () => songSelect?.Delete(beatmapSet))); + return items.ToArray(); + } + } + + private MenuItem createCollectionMenuItem(BeatmapCollection collection) + { + var beatmapSet = groupedBeatmapSet.BeatmapSet; + + TernaryState state; + + int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapMD5Hashes.Contains(b.MD5Hash)); + + if (countExisting == beatmapSet.Beatmaps.Count) + state = TernaryState.True; + else if (countExisting > 0) + state = TernaryState.Indeterminate; + else + state = TernaryState.False; + + var liveCollection = collection.ToLive(realm); + + return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s => + { + Task.Run(() => liveCollection.PerformWrite(c => + { + foreach (var b in beatmapSet.Beatmaps) + { + switch (s) + { + case TernaryState.True: + if (c.BeatmapMD5Hashes.Contains(b.MD5Hash)) + continue; + + c.BeatmapMD5Hashes.Add(b.MD5Hash); + break; + + case TernaryState.False: + c.BeatmapMD5Hashes.Remove(b.MD5Hash); + break; + } + } + })); + }) + { + State = { Value = state } + }; + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs new file mode 100644 index 0000000000..53ade139e2 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -0,0 +1,333 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelBeatmapStandalone : Panel + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private ISongSelect? songSelect { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + private IBindable? starDifficultyBindable; + private CancellationTokenSource? starDifficultyCancellationSource; + + private PanelSetBackground beatmapBackground = null!; + private ScheduledDelegate? scheduledBackgroundRetrieval; + + private OsuSpriteText titleText = null!; + private OsuSpriteText artistText = null!; + private PanelUpdateBeatmapButton updateButton = null!; + private BeatmapSetOnlineStatusPill statusPill = null!; + + private ConstrainedIconContainer difficultyIcon = null!; + private StarRatingDisplay starRatingDisplay = null!; + private StarCounter starCounter = null!; + private PanelLocalRankDisplay localRank = null!; + private OsuSpriteText keyCountText = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText authorText = null!; + private FillFlowContainer mainFill = null!; + + private Box backgroundBorder = null!; + + private BeatmapInfo beatmap => ((GroupedBeatmap)Item!.Model).Beatmap; + + public PanelBeatmapStandalone() + { + PanelXOffset = 20; + } + + [BackgroundDependencyLoader] + private void load() + { + Height = HEIGHT; + + Icon = difficultyIcon = new ConstrainedIconContainer + { + Size = new Vector2(12), + Margin = new MarginPadding { Left = 4f, Right = 3f }, + Colour = colourProvider.Background5, + }; + + Background = backgroundBorder = new Box + { + RelativeSizeAxes = Axes.Both, + }; + + Content.Children = new Drawable[] + { + beatmapBackground = new PanelSetBackground(), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Spacing = new Vector2(5), + Margin = new MarginPadding { Left = 6.5f }, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + localRank = new PanelLocalRankDisplay + { + Scale = new Vector2(0.8f), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + }, + mainFill = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Bottom = 4.8f }, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + titleText = new OsuSpriteText + { + Font = OsuFont.Style.Heading2.With(typeface: Typeface.TorusAlternate, weight: FontWeight.Bold), + }, + artistText = new OsuSpriteText + { + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Padding = new MarginPadding { Top = -2 }, + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 2, Bottom = 2 }, + Children = new Drawable[] + { + statusPill = new BeatmapSetOnlineStatusPill + { + Animated = false, + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + TextSize = OsuFont.Style.Caption2.Size, + Margin = new MarginPadding { Right = 4f }, + }, + updateButton = new PanelUpdateBeatmapButton + { + Scale = new Vector2(0.8f), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 4f, Bottom = -1f }, + }, + keyCountText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Alpha = 0, + }, + difficultyText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 3f }, + }, + authorText = new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft + } + } + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small, animated: true) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.875f), + }, + starCounter = new StarCounter + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.4f) + } + }, + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => updateKeyCount()); + mods.BindValueChanged(_ => updateKeyCount(), true); + + Selected.BindValueChanged(s => Expanded.Value = s.NewValue, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + var beatmapSet = beatmap.BeatmapSet!; + + scheduledBackgroundRetrieval = Scheduler.AddDelayed(b => beatmapBackground.Beatmap = beatmaps.GetWorkingBeatmap(b), beatmap, 50); + + titleText.Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title); + artistText.Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist); + updateButton.BeatmapSet = beatmapSet; + statusPill.Status = beatmap.Status; + + difficultyIcon.Icon = beatmap.Ruleset.CreateInstance().CreateIcon(); + difficultyIcon.Show(); + + localRank.Beatmap = beatmap; + difficultyText.Text = beatmap.DifficultyName; + authorText.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); + + computeStarRating(); + updateKeyCount(); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + scheduledBackgroundRetrieval?.Cancel(); + scheduledBackgroundRetrieval = null; + beatmapBackground.Beatmap = null; + updateButton.BeatmapSet = null; + localRank.Beatmap = null; + starDifficultyBindable = null; + + starDifficultyCancellationSource?.Cancel(); + } + + private void computeStarRating() + { + starDifficultyCancellationSource?.Cancel(); + starDifficultyCancellationSource = new CancellationTokenSource(); + + if (Item == null) + return; + + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE); + starDifficultyBindable.BindValueChanged(starDifficulty => + { + starRatingDisplay.Current.Value = starDifficulty.NewValue; + starCounter.Current = (float)starDifficulty.NewValue.Stars; + }, true); + } + + protected override void Update() + { + base.Update(); + + if (Item?.IsVisible != true) + { + starDifficultyCancellationSource?.Cancel(); + starDifficultyCancellationSource = null; + } + + // Dirty hack to make sure we don't take up spacing in parent fill flow when not displaying a rank. + // I can't find a better way to do this. + mainFill.Margin = new MarginPadding { Left = 1 / starRatingDisplay.Scale.X * (localRank.HasRank ? 0 : -3) }; + + var diffColour = starRatingDisplay.DisplayedDifficultyColour; + + AccentColour = diffColour; + starCounter.Colour = diffColour; + + backgroundBorder.Colour = diffColour; + difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; + } + + private void updateKeyCount() + { + if (Item == null) + return; + + if (ruleset.Value.OnlineID == 3) + { + // Account for mania differences locally for now. + // Eventually this should be handled in a more modular way, allowing rulesets to add more information to the panel. + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + int keyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); + + keyCountText.Alpha = 1; + keyCountText.Text = $"[{keyCount}K] "; + } + else + keyCountText.Alpha = 0; + } + + public override MenuItem[] ContextMenuItems + { + get + { + if (Item == null) + return Array.Empty(); + + List items = new List(); + + if (songSelect != null) + items.AddRange(songSelect.GetForwardActions(beatmap)); + + return items.ToArray(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelGroup.cs b/osu.Game/Screens/SelectV2/PanelGroup.cs new file mode 100644 index 0000000000..d2ae495610 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelGroup.cs @@ -0,0 +1,178 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelGroup : Panel + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.2f; + + private Drawable iconContainer = null!; + private OsuSpriteText titleText = null!; + private TrianglesV2 triangles = null!; + private CircularContainer countPill = null!; + private OsuSpriteText countText = null!; + private Box glow = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = HEIGHT; + + Icon = iconContainer = new Container + { + AlwaysPresent = true, + RelativeSizeAxes = Axes.Y, + Alpha = 0f, + Child = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(12), + Colour = colourProvider.Background3, + }, + }; + + Background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Highlight1, + }; + + AccentColour = colourProvider.Highlight1; + Content.Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Thickness = 0.02f, + SpawnRatio = 0.6f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background6, colourProvider.Background5) + }, + glow = new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Highlight1, colourProvider.Highlight1.Opacity(0f)), + }, + titleText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Heading2, + UseFullGlyphHeight = false, + X = 10f, + }, + countPill = new CircularContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(50f, 14f), + Margin = new MarginPadding { Right = 30f }, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.7f), + }, + countText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + UseFullGlyphHeight = false, + } + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + } + + private void onExpanded() + { + const float duration = 500; + + iconContainer.ResizeWidthTo(Expanded.Value ? 20f : 5f, duration, Easing.OutQuint); + iconContainer.FadeTo(Expanded.Value ? 1f : 0f, duration, Easing.OutQuint); + + ColourInfo colour = Expanded.Value + ? ColourInfo.GradientHorizontal(colourProvider.Highlight1.Opacity(0.25f), colourProvider.Highlight1.Opacity(0f)) + : ColourInfo.GradientHorizontal(colourProvider.Background6, colourProvider.Background5); + + triangles.FadeColour(colour, duration, Easing.OutQuint); + glow.FadeTo(Expanded.Value ? 0.4f : 0, duration, Easing.OutQuint); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + GroupDefinition group = (GroupDefinition)Item.Model; + + titleText.Text = group.Title; + countText.Text = Item.NestedItemCount.ToString("N0"); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + // Move the count pill in the opposite direction to keep it pinned to the screen regardless of the X position of TopLevelContent. + countPill.X = -TopLevelContent.X; + } + + public override MenuItem[] ContextMenuItems + { + get + { + if (Item == null) + return Array.Empty(); + + return new MenuItem[] + { + new OsuMenuItem(Expanded.Value ? WebCommonStrings.ButtonsCollapse.ToSentence() : WebCommonStrings.ButtonsExpand.ToSentence(), MenuItemType.Highlighted, () => TriggerClick()) + }; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelGroupRankDisplay.cs b/osu.Game/Screens/SelectV2/PanelGroupRankDisplay.cs new file mode 100644 index 0000000000..6895c30fee --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelGroupRankDisplay.cs @@ -0,0 +1,226 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelGroupRankDisplay : Panel + { + public const float HEIGHT = PanelGroup.HEIGHT; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Drawable iconContainer = null!; + private Box backgroundBorder = null!; + private Box contentBackground = null!; + private OsuSpriteText starRatingText = null!; + private CircularContainer countPill = null!; + private OsuSpriteText countText = null!; + private TrianglesV2 triangles = null!; + private Box glow = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = PanelGroup.HEIGHT; + + Icon = iconContainer = new Container + { + AlwaysPresent = true, + RelativeSizeAxes = Axes.Y, + Alpha = 0f, + Child = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(12), + }, + }; + + Background = backgroundBorder = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Highlight1, + }; + + AccentColour = colourProvider.Highlight1; + Content.Children = new Drawable[] + { + contentBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Thickness = 0.02f, + SpawnRatio = 0.6f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background6, colourProvider.Background5) + }, + glow = new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Highlight1, colourProvider.Highlight1.Opacity(0f)), + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10f, 0f), + Margin = new MarginPadding { Left = 10f }, + Children = new Drawable[] + { + starRatingText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + Font = OsuFont.Style.Heading2, + } + } + }, + countPill = new CircularContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(50f, 14f), + Margin = new MarginPadding { Right = 30f }, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.7f), + }, + countText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + UseFullGlyphHeight = false, + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + } + + private Color4 rankColour; + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + var group = (RankDisplayGroupDefinition)Item.Model; + ScoreRank rank = group.Rank; + + rankColour = OsuColour.ForRank(rank); + + AccentColour = rankColour; + backgroundBorder.Colour = rankColour; + contentBackground.Colour = rankColour.Darken(1f); + glow.Colour = ColourInfo.GradientHorizontal(rankColour, rankColour.Opacity(0f)); + + switch (rank) + { + case ScoreRank.SH: + case ScoreRank.XH: + starRatingText.Colour = DrawableRank.GetRankLetterColour(rank); + iconContainer.Colour = colourProvider.Background5; + break; + + case ScoreRank.X: + case ScoreRank.S: + starRatingText.Colour = DrawableRank.GetRankLetterColour(rank); + iconContainer.Colour = colourProvider.Background5; + break; + + case ScoreRank.F: + starRatingText.Colour = DrawableRank.GetRankLetterColour(rank); + iconContainer.Colour = colourProvider.Content1; + break; + + default: + starRatingText.Colour = Color4.White; + iconContainer.Colour = colourProvider.Background5; + break; + } + + starRatingText.Text = group.Title; + + ColourInfo colour = ColourInfo.GradientHorizontal(rankColour.Darken(0.6f), rankColour.Darken(0.8f)); + + triangles.Colour = colour; + + countText.Text = Item.NestedItemCount.ToLocalisableString(@"N0"); + + onExpanded(); + } + + private void onExpanded() + { + const float duration = 500; + + iconContainer.ResizeWidthTo(Expanded.Value ? 20f : 5f, duration, Easing.OutQuint); + iconContainer.FadeTo(Expanded.Value ? 1f : 0f, duration, Easing.OutQuint); + + glow.FadeTo(Expanded.Value ? 0.4f : 0, duration, Easing.OutQuint); + } + + protected override void Update() + { + base.Update(); + + // Move the count pill in the opposite direction to keep it pinned to the screen regardless of the X position of TopLevelContent. + countPill.X = -TopLevelContent.X; + } + + public override MenuItem[] ContextMenuItems + { + get + { + if (Item == null) + return Array.Empty(); + + return new MenuItem[] + { + new OsuMenuItem(Expanded.Value ? WebCommonStrings.ButtonsCollapse.ToSentence() : WebCommonStrings.ButtonsExpand.ToSentence(), MenuItemType.Highlighted, () => TriggerClick()) + }; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelGroupRankedStatus.cs b/osu.Game/Screens/SelectV2/PanelGroupRankedStatus.cs new file mode 100644 index 0000000000..ce175efcf6 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelGroupRankedStatus.cs @@ -0,0 +1,216 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelGroupRankedStatus : Panel + { + public const float HEIGHT = PanelGroup.HEIGHT; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Drawable iconContainer = null!; + private Box backgroundBorder = null!; + private Box contentBackground = null!; + private OsuSpriteText starRatingText = null!; + private CircularContainer countPill = null!; + private OsuSpriteText countText = null!; + private TrianglesV2 triangles = null!; + private Box glow = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = PanelGroup.HEIGHT; + + Icon = iconContainer = new Container + { + AlwaysPresent = true, + RelativeSizeAxes = Axes.Y, + Alpha = 0f, + Child = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(12), + }, + }; + + Background = backgroundBorder = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Highlight1, + }; + + AccentColour = colourProvider.Highlight1; + Content.Children = new Drawable[] + { + contentBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Thickness = 0.02f, + SpawnRatio = 0.6f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background6, colourProvider.Background5) + }, + glow = new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Highlight1, colourProvider.Highlight1.Opacity(0f)), + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10f, 0f), + Margin = new MarginPadding { Left = 10f }, + Children = new Drawable[] + { + starRatingText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + Font = OsuFont.Style.Heading2, + } + } + }, + countPill = new CircularContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(50f, 14f), + Margin = new MarginPadding { Right = 30f }, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.7f), + }, + countText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + UseFullGlyphHeight = false, + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + } + + private Color4 statusColour; + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + var group = (RankedStatusGroupDefinition)Item.Model; + BeatmapOnlineStatus status = group.Status; + + statusColour = OsuColour.ForBeatmapSetOnlineStatus(status) ?? throw new ArgumentOutOfRangeException(nameof(status), status, null); + + switch (status) + { + case BeatmapOnlineStatus.Graveyard: + // special override - the colour returned by `ForBeatmapSetOnlineStatus()` for graveyard is pitch black and doesn't allow for any contrast + statusColour = colours.Gray5; + iconContainer.Colour = Color4.White; + break; + + default: + iconContainer.Colour = colourProvider.Background5; + break; + } + + AccentColour = statusColour; + backgroundBorder.Colour = statusColour; + contentBackground.Colour = statusColour.Darken(1f); + glow.Colour = ColourInfo.GradientHorizontal(statusColour, statusColour.Opacity(0f)); + + starRatingText.Text = group.Title; + + ColourInfo colour = ColourInfo.GradientHorizontal(statusColour.Darken(0.6f), statusColour.Darken(0.8f)); + + triangles.Colour = colour; + + countText.Text = Item.NestedItemCount.ToLocalisableString(@"N0"); + + onExpanded(); + } + + private void onExpanded() + { + const float duration = 500; + + iconContainer.ResizeWidthTo(Expanded.Value ? 20f : 5f, duration, Easing.OutQuint); + iconContainer.FadeTo(Expanded.Value ? 1f : 0f, duration, Easing.OutQuint); + + glow.FadeTo(Expanded.Value ? 0.4f : 0, duration, Easing.OutQuint); + } + + protected override void Update() + { + base.Update(); + + // Move the count pill in the opposite direction to keep it pinned to the screen regardless of the X position of TopLevelContent. + countPill.X = -TopLevelContent.X; + } + + public override MenuItem[] ContextMenuItems + { + get + { + if (Item == null) + return Array.Empty(); + + return new MenuItem[] + { + new OsuMenuItem(Expanded.Value ? WebCommonStrings.ButtonsCollapse.ToSentence() : WebCommonStrings.ButtonsExpand.ToSentence(), MenuItemType.Highlighted, () => TriggerClick()) + }; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs new file mode 100644 index 0000000000..e6b59334cd --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs @@ -0,0 +1,224 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelGroupStarDifficulty : Panel + { + public const float HEIGHT = PanelGroup.HEIGHT; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Drawable iconContainer = null!; + private Box backgroundBorder = null!; + private Box contentBackground = null!; + private OsuSpriteText starRatingText = null!; + private CircularContainer countPill = null!; + private OsuSpriteText countText = null!; + private TrianglesV2 triangles = null!; + private Box glow = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = PanelGroup.HEIGHT; + + Icon = iconContainer = new Container + { + AlwaysPresent = true, + RelativeSizeAxes = Axes.Y, + Alpha = 0f, + Child = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronDown, + Size = new Vector2(12), + }, + }; + + Background = backgroundBorder = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Highlight1, + }; + + AccentColour = colourProvider.Highlight1; + Content.Children = new Drawable[] + { + contentBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Thickness = 0.02f, + SpawnRatio = 0.6f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background6, colourProvider.Background5) + }, + glow = new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Highlight1, colourProvider.Highlight1.Opacity(0f)), + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10f, 0f), + Margin = new MarginPadding { Left = 10f }, + Children = new Drawable[] + { + starRatingText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + Font = OsuFont.Style.Heading2, + } + } + }, + countPill = new CircularContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(50f, 14f), + Margin = new MarginPadding { Right = 30f }, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.7f), + }, + countText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + UseFullGlyphHeight = false, + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(_ => onExpanded(), true); + } + + private Color4 ratingColour; + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + var group = (StarDifficultyGroupDefinition)Item.Model; + int starNumber = (int)group.Difficulty.Stars; + + ratingColour = starNumber >= 9 ? OsuColour.Gray(0.2f) : colours.ForStarDifficulty(starNumber); + + AccentColour = ratingColour; + backgroundBorder.Colour = ratingColour; + contentBackground.Colour = ratingColour.Darken(1f); + glow.Colour = ColourInfo.GradientHorizontal(ratingColour, ratingColour.Opacity(0f)); + + switch (starNumber) + { + case 0: + starRatingText.Text = @"Below 1 Star"; + break; + + case 1: + starRatingText.Text = @"1 Star"; + break; + + default: + starRatingText.Text = $"{starNumber} Stars"; + break; + } + + iconContainer.Colour = starNumber >= 7 ? colourProvider.Content1 : colourProvider.Background5; + starRatingText.Colour = colourProvider.Content1; + starRatingText.Text = group.Title; + + ColourInfo colour; + + if (starNumber >= 8) + colour = ColourInfo.GradientHorizontal(ratingColour, ratingColour.Darken(0.2f)); + else + colour = ColourInfo.GradientHorizontal(ratingColour.Darken(0.6f), ratingColour.Darken(0.8f)); + + triangles.Colour = colour; + + countText.Text = Item.NestedItemCount.ToLocalisableString(@"N0"); + + onExpanded(); + } + + private void onExpanded() + { + const float duration = 500; + + iconContainer.ResizeWidthTo(Expanded.Value ? 20f : 5f, duration, Easing.OutQuint); + iconContainer.FadeTo(Expanded.Value ? 1f : 0f, duration, Easing.OutQuint); + + glow.FadeTo(Expanded.Value ? 0.4f : 0, duration, Easing.OutQuint); + } + + protected override void Update() + { + base.Update(); + + // Move the count pill in the opposite direction to keep it pinned to the screen regardless of the X position of TopLevelContent. + countPill.X = -TopLevelContent.X; + } + + public override MenuItem[] ContextMenuItems + { + get + { + if (Item == null) + return Array.Empty(); + + return new MenuItem[] + { + new OsuMenuItem(Expanded.Value ? WebCommonStrings.ButtonsCollapse.ToSentence() : WebCommonStrings.ButtonsExpand.ToSentence(), MenuItemType.Highlighted, () => TriggerClick()) + }; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs b/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs new file mode 100644 index 0000000000..c72835144f --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.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 System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osuTK; +using Realms; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelLocalRankDisplay : CompositeDrawable + { + private BeatmapInfo? beatmap; + + public BeatmapInfo? Beatmap + { + get => beatmap; + set + { + beatmap = value; + + if (IsLoaded) + updateSubscription(); + } + } + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IDisposable? scoreSubscription; + + private readonly UpdateableRank updateable; + + public bool HasRank => updateable.Rank != null; + + public PanelLocalRankDisplay(BeatmapInfo? beatmap = null) + { + AutoSizeAxes = Axes.Both; + + InternalChild = updateable = new UpdateableRank(animate: false) + { + Size = new Vector2(40, 20), + Alpha = 0, + }; + + Beatmap = beatmap; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => updateSubscription(), true); + } + + private void updateSubscription() + { + scoreSubscription?.Dispose(); + + if (beatmap == null) + return; + + scoreSubscription = realm.RegisterForNotifications(r => + r.GetAllLocalScoresForUser(api.LocalUser.Value.Id) + .Filter($@"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" + + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1", beatmap.ID, ruleset.Value.ShortName), + localScoresChanged); + } + + private void localScoresChanged(IRealmCollection sender, ChangeSet? changes) + { + // This subscription may fire from changes to linked beatmaps, which we don't care about. + // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. + if (changes?.HasCollectionChanges() == false) + return; + + ScoreInfo? topScore = sender.MaxBy(info => (info.TotalScore, -info.Date.UtcDateTime.Ticks)); + updateable.Rank = topScore?.Rank; + updateable.Alpha = topScore != null ? 1 : 0; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + scoreSubscription?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelSetBackground.cs b/osu.Game/Screens/SelectV2/PanelSetBackground.cs new file mode 100644 index 0000000000..7f15a23b9a --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelSetBackground.cs @@ -0,0 +1,195 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.PolygonExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelSetBackground : Container + { + [Resolved] + private BeatmapCarousel? beatmapCarousel { get; set; } + + private Sprite? sprite; + + private WorkingBeatmap? working; + + private CancellationTokenSource? loadCancellation; + + private double timeSinceUnpool; + + public WorkingBeatmap? Beatmap + { + get => working; + set + { + if (working == null && value == null) + return; + + // this guard papers over excessive refreshes of the background asset which occur if `working == value` type guards are used. + // the root cause of why `working == value` type guards fail here is that `SongSelect` will invalidate working beatmaps very often + // (via https://github.com/ppy/osu/blob/d3ae20dd882381e109c20ca00ee5237e4dd1750d/osu.Game/Screens/SelectV2/SongSelect.cs#L506-L507), + // due to a variety of causes, ranging from "someone typed a letter in the search box" (which triggers a refilter -> presentation of new items -> `ensureGlobalBeatmapValid()`), + // to "someone just went into the editor and replaced every single file in the set, including the background". + // the following guard approximates the most appropriate debounce criterion, which is the contents of the actual asset that is supposed to be displayed in the background, + // i.e. if the hash of the new background file matches the old, then we do not bother updating the working beatmap here. + // + // note that this is basically a reimplementation of the caching scheme in `WorkingBeatmapCache.getBackgroundFromStore()`, + // which cannot be used directly by retrieving the texture and checking texture reference equality, + // because missing the cache would incur a synchronous texture load on the update thread. + if (getBackgroundFileHash(working) == getBackgroundFileHash(value)) + return; + + working = value; + + loadCancellation?.Cancel(); + loadCancellation = null; + + sprite?.Expire(); + sprite = null; + + timeSinceUnpool = 0; + } + } + + private static string? getBackgroundFileHash(WorkingBeatmap? working) + => working?.BeatmapSetInfo.GetFile(working.Metadata.BackgroundFile)?.File.Hash; + + public PanelSetBackground() + { + RelativeSizeAxes = Axes.Both; + CornerRadius = Panel.CORNER_RADIUS; + Masking = true; + + // Add some level of smoothness around the rounded edges to give more visual polish (make it anti-aliased). + MaskingSmoothness = 2f; + } + + protected override void Update() + { + base.Update(); + + loadContentIfRequired(); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChildren = new Drawable[] + { + new Box + { + Depth = 1, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background3, colourProvider.Background4), + }, + new FillFlowContainer + { + Depth = -1, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle + Shear = new Vector2(0.8f, 0), + Children = new[] + { + // The left half with no gradient applied + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.5f), + Width = 0.4f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.5f), Color4.Black.Opacity(0.3f)), + Width = 0.2f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0.2f)), + // Slightly more than 1.0 in total to account for shear. + Width = 0.45f, + }, + } + }, + }; + } + + private void loadContentIfRequired() + { + // A load is already in progress if the cancellation token is non-null. + if (loadCancellation != null || working == null) + return; + + if (beatmapCarousel != null) + { + Quad containingSsdq = beatmapCarousel.ScreenSpaceDrawQuad; + + // One may ask why we are not using `DelayedLoadWrapper` for this delayed load logic. + // + // - Using `DelayedLoadWrapper` would only allow us to load content when on screen, but we want to preload while panels are off-screen. + // This allows a more seamless experience when a user is scrolling at a moderate speed, as we are loading in backgrounds before they + // enter the visible viewport. + // - By using a slightly customised formula to decide when to start the load, we can coerce the loading of backgrounds into an order that + // prioritises panels which are closest to the centre of the screen. Basically, we want to load backgrounds "outwards" from the visual + // centre to give the user the best experience possible. + float timeUpdatingBeforeLoad = Math.Abs(containingSsdq.Centre.Y - ScreenSpaceDrawQuad.Centre.Y) / containingSsdq.Height * 100; + + timeSinceUnpool += Time.Elapsed; + + // We only trigger a load after this set has been in an updating state for a set amount of time. + if (timeSinceUnpool <= timeUpdatingBeforeLoad) + return; + } + + loadCancellation = new CancellationTokenSource(); + + LoadComponentAsync(new PanelBeatmapBackground(working) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + }, s => + { + AddInternal(sprite = s); + bool spriteOnScreen = beatmapCarousel?.ScreenSpaceDrawQuad.Intersects(sprite.ScreenSpaceDrawQuad) != false; + sprite.FadeInFromZero(spriteOnScreen ? 400 : 0, Easing.OutQuint); + }, loadCancellation.Token); + } + + public partial class PanelBeatmapBackground : Sprite + { + private readonly IWorkingBeatmap working; + + public PanelBeatmapBackground(IWorkingBeatmap working) + { + ArgumentNullException.ThrowIfNull(working); + + this.working = working; + } + + [BackgroundDependencyLoader] + private void load() + { + Texture = working.GetPanelBackground(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelUpdateBeatmapButton.cs b/osu.Game/Screens/SelectV2/PanelUpdateBeatmapButton.cs new file mode 100644 index 0000000000..e7204eefaf --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelUpdateBeatmapButton.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.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Screens.Select.Carousel; +using osuTK; +using osuTK.Graphics; +using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelUpdateBeatmapButton : OsuAnimatedButton + { + private BeatmapSetInfo? beatmapSet; + + public BeatmapSetInfo? BeatmapSet + { + get => beatmapSet; + set + { + beatmapSet = value; + + if (IsLoaded) + beatmapChanged(); + } + } + + private SpriteIcon icon = null!; + private Box progressFill = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private LoginOverlay? loginOverlay { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + public PanelUpdateBeatmapButton() + { + AutoSizeAxes = Axes.X; + Height = 22f; + } + + private Bindable preferNoVideo = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + const float icon_size = 12; + + preferNoVideo = config.GetBindable(OsuSetting.PreferNoVideo); + + Content.Anchor = Anchor.Centre; + Content.Origin = Anchor.Centre; + + Content.AddRange(new Drawable[] + { + progressFill = new Box + { + Colour = Color4.White, + Alpha = 0.2f, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Width = 0, + }, + new FillFlowContainer + { + Padding = new MarginPadding { Horizontal = 5, Vertical = 3 }, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new Container + { + Size = new Vector2(icon_size), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.SyncAlt, + Size = new Vector2(icon_size), + }, + } + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Text = CommonStrings.ButtonsUpdate, + } + } + }, + }); + + Action = performUpdate; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + beatmapChanged(); + } + + private void beatmapChanged() + { + Alpha = beatmapSet?.AllBeatmapsUpToDate == false ? 1 : 0; + icon.Spin(4000, RotationDirection.Clockwise); + } + + protected override bool OnHover(HoverEvent e) + { + icon.Spin(400, RotationDirection.Clockwise, icon.Rotation); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + icon.Spin(4000, RotationDirection.Clockwise, icon.Rotation); + base.OnHoverLost(e); + } + + public override LocalisableString TooltipText => Enabled.Value ? SongSelectStrings.UpdateBeatmapTooltip : string.Empty; + + private bool updateConfirmed; + + private void performUpdate() + { + Debug.Assert(beatmapSet != null); + + if (!api.IsLoggedIn) + { + loginOverlay?.Show(); + return; + } + + if (dialogOverlay != null && beatmapSet.Status == BeatmapOnlineStatus.LocallyModified && !updateConfirmed) + { + dialogOverlay.Push(new UpdateLocalConfirmationDialog(() => + { + updateConfirmed = true; + performUpdate(); + })); + + return; + } + + updateConfirmed = false; + + beatmapDownloader.DownloadAsUpdate(beatmapSet, preferNoVideo.Value); + attachExistingDownload(); + } + + private void attachExistingDownload() + { + Debug.Assert(beatmapSet != null); + var download = beatmapDownloader.GetExistingDownload(beatmapSet); + + if (download != null) + { + Enabled.Value = false; + + download.DownloadProgressed += progress => progressFill.ResizeWidthTo(progress, 100, Easing.OutQuint); + download.Failure += _ => attachExistingDownload(); + } + else + { + Enabled.Value = true; + + progressFill.ResizeWidthTo(0, 100, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs b/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs new file mode 100644 index 0000000000..16df414037 --- /dev/null +++ b/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.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 System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using Realms; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// This component is designed to perform lookups of online data + /// and store portions of it for later local use to the realm database. + /// + /// + /// This component is designed to locally persist potentially-volatile online information such as: + /// + /// user tags assigned to difficulties of a beatmap, + /// the beatmap's , + /// guest mappers assigned to difficulties of a beatmap, + /// the local user's best score on a given beatmap. + /// + /// + public partial class RealmPopulatingOnlineLookupSource : Component + { + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public Task GetBeatmapSetAsync(int id, CancellationToken token = default) + { + var request = new GetBeatmapSetRequest(id); + var tcs = new TaskCompletionSource(); + + token.Register(() => request.Cancel()); + + // async request success callback is a bit of a dangerous game, but there's some reasoning for it. + // - don't really want to use `IAPIAccess.PerformAsync()` because we still want to respect request queueing & online status checks + // - we want the realm write here to be async because it is known to be slow for some users with large beatmap collections + // - at the time of writing `RealmAccess.WriteAsync()` can only be safely called from update thread, + // and API request completion callbacks are automatically marshaled onto update thread scheduler, + // so calling `WriteAsync()` within the callback is a somewhat "nice" way of guaranteeing that the call is safe + // (rather than having to enforce that `GetBeatmapSetAsync()` can only be called from update thread, or locally scheduling) + request.Success += async onlineBeatmapSet => + { + if (token.IsCancellationRequested) + { + tcs.SetCanceled(token); + return; + } + + await realm.WriteAsync(r => updateRealmBeatmapSet(r, onlineBeatmapSet)).ConfigureAwait(true); + tcs.SetResult(onlineBeatmapSet); + }; + request.Failure += tcs.SetException; + api.Queue(request); + return tcs.Task; + } + + private static void updateRealmBeatmapSet(Realm r, APIBeatmapSet onlineBeatmapSet) + { + var tagsById = (onlineBeatmapSet.RelatedTags ?? []).ToDictionary(t => t.Id); + var onlineBeatmaps = onlineBeatmapSet.Beatmaps.ToDictionary(b => b.OnlineID); + + var dbBeatmapSets = r.All().Where(b => b.OnlineID == onlineBeatmapSet.OnlineID); + + foreach (var dbBeatmapSet in dbBeatmapSets) + { + // note that every single write to realm models is preceded by a guard, even if it technically would write the same value back. + // the reason this matters is that doing so avoids triggering realm subscription callbacks. + // unfortunately in terms of subscriptions realm treats *every* write to any realm object as a modification, + // even if the write was redundant and had no observable effect. + + // notably, `LocallyModified` status is preserved on the set until the user performs an explicit action to get rid of it + // (be it updating the set or deciding to discard their changes, removing the set and re-downloading it, etc.) + if (dbBeatmapSet.Status != onlineBeatmapSet.Status && dbBeatmapSet.Status != BeatmapOnlineStatus.LocallyModified) + dbBeatmapSet.Status = onlineBeatmapSet.Status; + + foreach (var dbBeatmap in dbBeatmapSet.Beatmaps) + { + if (onlineBeatmaps.TryGetValue(dbBeatmap.OnlineID, out var onlineBeatmap)) + { + // compare `BeatmapUpdaterMetadataLookup` + if (dbBeatmap.OnlineMD5Hash != onlineBeatmap.MD5Hash) + dbBeatmap.OnlineMD5Hash = onlineBeatmap.MD5Hash; + + if (dbBeatmap.LastOnlineUpdate != onlineBeatmap.LastUpdated) + dbBeatmap.LastOnlineUpdate = onlineBeatmap.LastUpdated; + + if (dbBeatmap.MatchesOnlineVersion && dbBeatmap.Status != onlineBeatmap.Status) + dbBeatmap.Status = onlineBeatmap.Status; + + HashSet userTags = onlineBeatmap.TopTags? + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null) + .Select(t => t.relatedTag!.Name) + .ToHashSet() ?? []; + + if (!userTags.SetEquals(dbBeatmap.Metadata.UserTags)) + { + dbBeatmap.Metadata.UserTags.Clear(); + dbBeatmap.Metadata.UserTags.AddRange(userTags); + } + } + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/SoloSongSelect.cs b/osu.Game/Screens/SelectV2/SoloSongSelect.cs new file mode 100644 index 0000000000..697a1f3f55 --- /dev/null +++ b/osu.Game/Screens/SelectV2/SoloSongSelect.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.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select; +using osu.Game.Users; +using osu.Game.Utils; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class SoloSongSelect : SongSelect + { + protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); + + private PlayerLoader? playerLoader; + private IReadOnlyList? modsAtGameplayStart; + + [Resolved] + private BeatmapSetOverlay? beatmapOverlay { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notifications { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private OsuGame? game { get; set; } + + private Sample? sampleConfirmSelection { get; set; } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleConfirmSelection = audio.Samples.Get(@"SongSelect/confirm-selection"); + + AddInternal(new SongSelectTouchInputDetector()); + } + + public override IEnumerable GetForwardActions(BeatmapInfo beatmap) + { + yield return new OsuMenuItem(ButtonSystemStrings.Play.ToSentence(), MenuItemType.Highlighted, () => SelectAndRun(beatmap, OnStart)) { Icon = FontAwesome.Solid.Check }; + yield return new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => Edit(beatmap)) { Icon = FontAwesome.Solid.PencilAlt }; + + yield return new OsuMenuItemSpacer(); + + if (beatmap.OnlineID > 0) + { + yield return new OsuMenuItem(CommonStrings.Details, MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineID)); + + if (beatmap.GetOnlineURL(api, Ruleset.Value) is string url) + yield return new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyToClipboard(url)); + + yield return new OsuMenuItemSpacer(); + } + + foreach (var i in CreateCollectionMenuActions(beatmap)) + yield return i; + + if (beatmap.LastPlayed == null) + yield return new OsuMenuItem(SongSelectStrings.MarkAsPlayed, MenuItemType.Standard, () => beatmaps.MarkPlayed(beatmap)) { Icon = FontAwesome.Solid.TimesCircle }; + else + yield return new OsuMenuItem(SongSelectStrings.RemoveFromPlayed, MenuItemType.Standard, () => beatmaps.MarkNotPlayed(beatmap)) { Icon = FontAwesome.Solid.TimesCircle }; + + yield return new OsuMenuItem(SongSelectStrings.ClearAllLocalScores, MenuItemType.Standard, () => dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmap))) + { + Icon = FontAwesome.Solid.Eraser + }; + + if (beatmaps.CanHide(beatmap)) + yield return new OsuMenuItem(WebCommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => beatmaps.Hide(beatmap)); + } + + protected override void OnStart() + { + if (playerLoader != null) return; + + modsAtGameplayStart = Mods.Value.Select(m => m.DeepClone()).ToArray(); + + // Ctrl+Enter should start map with autoplay enabled. + if (GetContainingInputManager()?.CurrentState?.Keyboard.ControlPressed == true) + { + var autoInstance = getAutoplayMod(); + + if (autoInstance == null) + { + notifications?.Post(new SimpleNotification + { + Text = NotificationsStrings.NoAutoplayMod + }); + return; + } + + var mods = Mods.Value.Append(autoInstance).ToArray(); + + if (!ModUtils.CheckCompatibleSet(mods, out var invalid)) + mods = mods.Except(invalid).Append(autoInstance).ToArray(); + + Mods.Value = mods; + } + + sampleConfirmSelection?.Play(); + + this.Push(playerLoader = new PlayerLoader(createPlayer)); + + Player createPlayer() + { + Player player; + + var replayGeneratingMod = Mods.Value.OfType().FirstOrDefault(); + + if (replayGeneratingMod != null) + { + player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)); + } + else + { + player = new SoloPlayer(); + } + + return player; + } + } + + public void Edit(BeatmapInfo beatmap) + { + if (!this.IsCurrentScreen()) + return; + + SelectAndRun(beatmap, () => this.Push(new EditorLoader())); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + revertMods(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + revertMods(); + return false; + } + + private ModAutoplay? getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); + + private void revertMods() + { + if (playerLoader == null) return; + + Mods.Value = modsAtGameplayStart; + playerLoader = null; + } + + private partial class PlayerLoader : Play.PlayerLoader + { + public override bool ShowFooter => !QuickRestart; + + public PlayerLoader(Func createPlayer) + : base(createPlayer) + { + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs new file mode 100644 index 0000000000..e8843876d3 --- /dev/null +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -0,0 +1,1212 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Development; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Volume; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select; +using osu.Game.Skinning; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// This screen is intended to house all components introduced in the new song select design to add transitions and examine the overall look. + /// This will be gradually built upon and ultimately replace once everything is in place. + /// + [Cached(typeof(ISongSelect))] + public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler, ISongSelect, IHandlePresentBeatmap + { + /// + /// A debounce that governs how long after a panel is selected before the rest of song select (and the game at large) + /// updates to show that selection. + /// + /// This is intentionally slightly higher than key repeat, but low enough to not impede user experience. + /// + public const int SELECTION_DEBOUNCE = 150; + + /// + /// A general "global" debounce to be applied to anything aggressive difficulty calculation at song select, + /// either after selection or after a panel comes on screen. Value should be low enough that users don't complain, + /// but otherwise as high as possible to reduce overheads. + /// + public const int DIFFICULTY_CALCULATION_DEBOUNCE = 150; + + private const float logo_scale = 0.4f; + private const double fade_duration = 300; + + public const float WEDGE_CONTENT_MARGIN = CORNER_RADIUS_HIDE_OFFSET + OsuGame.SCREEN_EDGE_MARGIN; + public const float CORNER_RADIUS_HIDE_OFFSET = 20f; + public const float ENTER_DURATION = 600; + + /// + /// Whether this song select instance should take control of the global track, + /// applying looping and preview offsets. + /// + protected bool ControlGlobalMusic { get; init; } = true; + + // Colour scheme for mod overlay is left as default (green) to match mods button. + // Not sure about this, but we'll iterate based on feedback. + private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay + { + ShowPresets = true, + }; + + private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; + + // Blue is the most neutral choice, so I'm using that for now. + // Purple makes the most sense to match the "gameplay" flow, but it's a bit too strong for the current design. + // TODO: Colour scheme choice should probably be customisable by the user. + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private BeatmapCarousel carousel = null!; + + private FilterControl filterControl = null!; + private BeatmapTitleWedge titleWedge = null!; + private BeatmapDetailsArea detailsArea = null!; + private FillFlowContainer wedgesContainer = null!; + private Box rightGradientBackground = null!; + private Container mainContent = null!; + private SkinnableContainer skinnableContent = null!; + + private NoResultsPlaceholder noResultsPlaceholder = null!; + + public override bool? ApplyModTrackAdjustments => true; + + public override bool ShowFooter => true; + + private Sample? errorSample; + + [Resolved] + private OsuGameBase? game { get; set; } + + [Resolved] + private OsuLogo? logo { get; set; } + + [Resolved] + private BeatmapSetOverlay? beatmapOverlay { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private ManageCollectionsDialog? collectionsDialog { get; set; } + + [Resolved] + private DifficultyRecommender? difficultyRecommender { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + private InputManager inputManager = null!; + + private readonly RealmPopulatingOnlineLookupSource onlineLookupSource = new RealmPopulatingOnlineLookupSource(); + + private Bindable configBackgroundBlur = null!; + private Bindable showConvertedBeatmaps = null!; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, OsuConfigManager config) + { + errorSample = audio.Samples.Get(@"UI/generic-error"); + + AddRangeInternal(new Drawable[] + { + new GlobalScrollAdjustsVolume(), + onlineLookupSource, + mainContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = ScreenFooter.HEIGHT }, + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0.6f, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0f)), + }, + mainGridContainer = new GridContainer // used for max width implementation + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + // Ensure the left components are on top of the carousel both visually (although they should never overlay) + // but more importantly, for input purposes to allow the scroll-to-selection logic to override carousel's + // screen-wide scroll handling. + Depth = float.MinValue, + Shear = OsuGame.SHEAR, + Padding = new MarginPadding + { + Top = -CORNER_RADIUS_HIDE_OFFSET, + Left = -CORNER_RADIUS_HIDE_OFFSET, + }, + Children = new Drawable[] + { + new Container + { + // Pad enough to only reset scroll when well into the left wedge areas. + Padding = new MarginPadding { Right = 40 }, + RelativeSizeAxes = Axes.Both, + Child = new Select.SongSelect.LeftSideInteractionContainer(() => + { + carousel.ExpandGroupForCurrentSelection(); + carousel.ScrollToSelection(); + }) + { + RelativeSizeAxes = Axes.Both, + }, + }, + wedgesContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Spacing = new Vector2(0f, 4f), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new ShearAligningWrapper(titleWedge = new BeatmapTitleWedge()), + new ShearAligningWrapper(detailsArea = new BeatmapDetailsArea()), + }, + }, + } + }, + Empty(), + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + rightGradientBackground = new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.0f), Color4.Black.Opacity(0.5f)), + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Top = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, + Bottom = 5, + }, + Children = new Drawable[] + { + carousel = new BeatmapCarousel + { + BleedTop = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, + BleedBottom = ScreenFooter.HEIGHT + 5, + RelativeSizeAxes = Axes.Both, + RequestPresentBeatmap = b => SelectAndRun(b, OnStart), + RequestSelection = queueBeatmapSelection, + RequestRecommendedSelection = requestRecommendedSelection, + NewItemsPresented = newItemsPresented, + }, + noResultsPlaceholder = new NoResultsPlaceholder + { + RequestClearFilterText = () => filterControl.Search(string.Empty) + } + } + }, + filterControl = new FilterControl + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + }, + } + }, + }, + } + }, + } + }, + } + }, + skinnableContent = new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.SongSelect)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + modSpeedHotkeyHandler = new ModSpeedHotkeyHandler(), + modSelectOverlay, + }); + + configBackgroundBlur = config.GetBindable(OsuSetting.SongSelectBackgroundBlur); + configBackgroundBlur.BindValueChanged(e => + { + if (!this.IsCurrentScreen()) + return; + + updateBackgroundDim(); + }); + + showConvertedBeatmaps = config.GetBindable(OsuSetting.ShowConvertedBeatmaps); + } + + private void requestRecommendedSelection(IEnumerable groupedBeatmaps) + { + var recommendedBeatmap = difficultyRecommender?.GetRecommendedBeatmap(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap; + queueBeatmapSelection(groupedBeatmaps.First(bug => bug.Beatmap.Equals(recommendedBeatmap))); + } + + /// + /// Called when a selection is made to progress away from the song select screen. + /// + /// This is the default action which should be provided to . + /// + protected abstract void OnStart(); + + public override IReadOnlyList CreateFooterButtons() => new ScreenFooterButton[] + { + new FooterButtonMods(modSelectOverlay) + { + Hotkey = GlobalAction.ToggleModSelection, + Current = Mods, + RequestDeselectAllMods = () => + { + if (modSelectOverlay.State.Value == Visibility.Visible) + modSelectOverlay.DeselectAll(); + else + Mods.Value = Array.Empty(); + } + }, + new FooterButtonRandom + { + NextRandom = () => + { + if (!carousel.NextRandom()) + errorSample?.Play(); + }, + PreviousRandom = () => + { + if (!carousel.PreviousRandom()) + errorSample?.Play(); + } + }, + new FooterButtonOptions + { + Hotkey = GlobalAction.ToggleBeatmapOptions, + } + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager()!; + + filterControl.CriteriaChanged += criteriaChanged; + + modSelectOverlay.State.BindValueChanged(v => + { + if (!this.IsCurrentScreen()) + return; + + logo?.FadeTo(v.NewValue == Visibility.Visible ? 0f : 1f, 200, Easing.OutQuint); + }); + + Beatmap.BindValueChanged(_ => + { + if (!this.IsCurrentScreen()) + return; + + ensureGlobalBeatmapValid(); + + ensurePlayingSelected(); + updateBackgroundDim(); + updateWedgeVisibility(); + fetchOnlineInfo(); + }); + } + + protected override void Update() + { + base.Update(); + + detailsArea.Height = wedgesContainer.DrawHeight - titleWedge.LayoutSize.Y - 4; + + float widescreenBonusWidth = Math.Max(0, DrawWidth / DrawHeight - 2f); + + mainGridContainer.ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 700 + widescreenBonusWidth * 100), + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.5f, minSize: 500, maxSize: 700 + widescreenBonusWidth * 300), + }; + + if (this.IsCurrentScreen()) + updateDebounce(); + } + + #region Selection debounce + + private BeatmapInfo? debounceQueuedSelection; + private double debounceElapsedTime; + + private void debounceQueueSelection(BeatmapInfo beatmap) + { + debounceQueuedSelection = beatmap; + debounceElapsedTime = 0; + } + + private void updateDebounce() + { + if (debounceQueuedSelection == null) return; + + double elapsed = Clock.ElapsedFrameTime; + + // When a key is being held, assume the user is traversing the carousel using key repeat. + // We want to change panels less often in this state (basically making debounce longer than initial key repeat, at least). + double debounceInterval = inputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed ? SELECTION_DEBOUNCE * 2 : SELECTION_DEBOUNCE; + + // avoid debounce running early if there's a single long frame. + if (!DebugUtils.IsNUnitRunning && Clock.FramesPerSecond > 0) + elapsed = Math.Min(1000 / Clock.FramesPerSecond, elapsed); + + debounceElapsedTime += elapsed; + + if (debounceElapsedTime >= debounceInterval) + performDebounceSelection(); + } + + private void performDebounceSelection() + { + if (debounceQueuedSelection == null) return; + + try + { + if (Beatmap.Value.BeatmapInfo.Equals(debounceQueuedSelection)) + return; + + Beatmap.Value = beatmaps.GetWorkingBeatmap(debounceQueuedSelection); + } + finally + { + cancelDebounceSelection(); + } + } + + private void cancelDebounceSelection() + { + debounceQueuedSelection = null; + debounceElapsedTime = 0; + } + + #endregion + + #region Audio + + [Resolved] + private MusicController music { get; set; } = null!; + + private readonly WeakReference lastTrack = new WeakReference(null); + + /// + /// Ensures some music is playing for the current track. + /// Will resume playback from a manual user pause if the track has changed. + /// + private void ensurePlayingSelected() + { + if (!ControlGlobalMusic) + return; + + ITrack track = music.CurrentTrack; + + bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; + + if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) + { + Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); + + // Only restart playback if a new track. + // This is important so that when exiting gameplay, the track is not restarted back to the preview point. + music.Play(isNewTrack); + } + + lastTrack.SetTarget(track); + } + + private bool isHandlingLooping; + + private void beginLooping() + { + Debug.Assert(!isHandlingLooping); + + isHandlingLooping = true; + + ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None); + + music.TrackChanged += ensureTrackLooping; + } + + private void endLooping() + { + // may be called multiple times during screen exit process. + if (!isHandlingLooping) + return; + + music.CurrentTrack.Looping = isHandlingLooping = false; + + music.TrackChanged -= ensureTrackLooping; + } + + private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) + => beatmap.PrepareTrackForPreview(true); + + #endregion + + #region Selection handling + + /// + /// Finalises selection on the given and runs the provided action if possible. + /// + /// The beatmap which should be selected. If not provided, the current globally selected beatmap will be used. + /// The action to perform if conditions are met to be able to proceed. May not be invoked if in an invalid state. + public void SelectAndRun(BeatmapInfo beatmap, Action startAction) + { + if (!this.IsCurrentScreen()) + return; + + if (!checkBeatmapValidForSelection(beatmap)) + return; + + // To ensure sanity, cancel any pending selection as we are about to force a selection. + // Carousel selection will update to the forced selection via a call of `ensureGlobalBeatmapValid` below, or when song select becomes current again. + cancelDebounceSelection(); + + // Forced refetch is important here to guarantee correct invalidation across all difficulties (editor specific). + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, true); + + if (Beatmap.IsDefault) + return; + + startAction(); + } + + /// + /// Prepares the proposed beatmap for global selection based on a carousel user-performed action. + /// + /// + /// Calling this method will: + /// - Immediately update the selection the carousel. + /// - After , update the global beatmap. This in turn causes song select visuals (title, details, leaderboard) to update. + /// This debounce is intended to avoid high overheads from churning lookups while a user is changing selection via rapid keyboard operations. + /// + /// The beatmap to be selected. + private void queueBeatmapSelection(GroupedBeatmap groupedBeatmap) + { + if (!this.IsCurrentScreen()) + return; + + carousel.CurrentGroupedBeatmap = groupedBeatmap; + + // Debounce consideration is to avoid beatmap churn on key repeat selection. + debounceQueueSelection(groupedBeatmap.Beatmap); + } + + private bool ensureGlobalBeatmapValid() + { + if (!this.IsCurrentScreen()) + return false; + + performDebounceSelection(); + + // While filtering, let's not ever attempt to change selection. + // This will be resolved after the filter completes, see `newItemsPresented`. + if (IsFiltering) + return false; + + // Refetch to be confident that the current selection is still valid. It may have been deleted or hidden. + var currentBeatmap = beatmaps.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo, true); + bool validSelection = checkBeatmapValidForSelection(currentBeatmap.BeatmapInfo); + + if (validSelection) + { + carousel.CurrentBeatmap = currentBeatmap.BeatmapInfo; + return true; + } + + // If there was no beatmap selected, pick a random one. + if (Beatmap.IsDefault) + { + validSelection = carousel.NextRandom(); + performDebounceSelection(); + return validSelection; + } + + // If a previous non-default selection became non-valid, it was likely hidden or deleted. + if (!validSelection) + { + // In the case a difficulty was hidden or removed, prefer selecting another difficulty from the same set. + var activeSet = currentBeatmap.BeatmapSetInfo; + + var validBeatmaps = activeSet.Beatmaps.Where(checkBeatmapValidForSelection).ToArray(); + + if (validBeatmaps.Any()) + { + var beatmap = difficultyRecommender?.GetRecommendedBeatmap(validBeatmaps) ?? validBeatmaps.First(); + carousel.CurrentBeatmap = beatmap; + debounceQueueSelection(beatmap); + return true; + } + } + + // If all else fails, use the default beatmap. + Beatmap.SetDefault(); + performDebounceSelection(); + + return validSelection; + } + + private bool checkBeatmapValidForSelection(BeatmapInfo beatmap) + { + if (!beatmap.AllowGameplayWithRuleset(Ruleset.Value, showConvertedBeatmaps.Value)) + return false; + + if (beatmap.Hidden) + return false; + + if (beatmap.BeatmapSet == null) + return false; + + if (beatmap.BeatmapSet.Protected || beatmap.BeatmapSet.DeletePending) + return false; + + return true; + } + + #endregion + + #region Transitions + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + this.FadeIn(); + onArrivingAtScreen(); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + this.FadeIn(fade_duration, Easing.OutQuint); + onArrivingAtScreen(); + + ensureGlobalBeatmapValid(); + + detailsArea.Refresh(); + + if (ControlGlobalMusic) + { + // restart playback on returning to song select, regardless. + // not sure this should be a permanent thing (we may want to leave a user pause paused even on returning) + music.ResetTrackAdjustments(); + music.Play(requestedByUser: true); + } + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + carousel.VisuallyFocusSelected = true; + + this.FadeOut(fade_duration, Easing.OutQuint); + onLeavingScreen(); + + base.OnSuspending(e); + } + + public override bool OnExiting(ScreenExitEvent e) + { + this.FadeOut(fade_duration, Easing.OutQuint); + onLeavingScreen(); + + return base.OnExiting(e); + } + + private void onArrivingAtScreen() + { + modSelectOverlay.Beatmap.BindTo(Beatmap); + // required due to https://github.com/ppy/osu-framework/issues/3218 + modSelectOverlay.SelectedMods.Disabled = false; + modSelectOverlay.SelectedMods.BindTo(Mods); + + carousel.VisuallyFocusSelected = false; + + updateWedgeVisibility(); + + if (ControlGlobalMusic) + { + // Avoid abruptly starting playback at preview point. + // Importantly, this should be done before looping is setup to ensure we get the correct imminent `IsPlaying` state. + if (!music.IsPlaying) + { + music.DuckMomentarily(0, new DuckParameters + { + DuckDuration = 0, + DuckVolumeTo = 0, + RestoreDuration = 800, + RestoreEasing = Easing.OutQuint + }); + } + + beginLooping(); + } + + ensureGlobalBeatmapValid(); + + ensurePlayingSelected(); + updateBackgroundDim(); + fetchOnlineInfo(force: true); + } + + private void onLeavingScreen() + { + restoreBackground(); + + modSelectOverlay.SelectedMods.UnbindFrom(Mods); + modSelectOverlay.Beatmap.UnbindFrom(Beatmap); + + updateWedgeVisibility(); + + endLooping(); + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (logo.Alpha > 0.8f && resuming) + Footer?.StartTrackingLogo(logo, 400, Easing.OutQuint); + else + { + logo.Hide(); + logo.ScaleTo(0.2f); + Footer?.StartTrackingLogo(logo); + } + + logo.FadeIn(240, Easing.OutQuint); + logo.ScaleTo(logo_scale, 240, Easing.OutQuint); + + logo.Action = () => + { + SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); + return false; + }; + } + + protected override void LogoSuspending(OsuLogo logo) + { + base.LogoSuspending(logo); + Footer?.StopTrackingLogo(); + } + + protected override void LogoExiting(OsuLogo logo) + { + base.LogoExiting(logo); + + Footer?.StopTrackingLogo(); + + logo.ScaleTo(0.2f, 120, Easing.Out); + logo.FadeOut(120, Easing.Out); + } + + private void updateWedgeVisibility() + { + // Ensure we don't show an invalid selection before the carousel has finished initially filtering. + // This avoids a flicker of a placeholder or invalid beatmap before a proper selection. + // + // After the carousel finishes filtering, it will attempt a selection then call this method again. + if (!CarouselItemsPresented && !checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo)) + return; + + if (carousel.VisuallyFocusSelected) + { + titleWedge.Hide(); + detailsArea.Hide(); + filterControl.Hide(); + } + else + { + titleWedge.Show(); + detailsArea.Show(); + filterControl.Show(); + } + } + + private void updateBackgroundDim() => ApplyToBackground(backgroundModeBeatmap => + { + backgroundModeBeatmap.Beatmap = Beatmap.Value; + backgroundModeBeatmap.IgnoreUserSettings.Value = true; + + backgroundModeBeatmap.DimWhenUserSettingsIgnored.Value = 0.1f; + + // Required to undo results screen dimming the background. + // Probably needs more thought because this needs to be in every `ApplyToBackground` currently to restore sane defaults. + backgroundModeBeatmap.FadeColour(Color4.White, 250); + + backgroundModeBeatmap.BlurAmount.Value = revealingBackground == null && configBackgroundBlur.Value ? 20 : 0f; + }); + + #endregion + + #region Filtering + + /// + /// Whether the carousel has finished initial presentation of beatmap panels. + /// + public bool CarouselItemsPresented { get; private set; } + + /// + /// Whether the carousel is or will be undergoing a filter operation. + /// + public bool IsFiltering => carousel.IsFiltering || filterDebounce?.State == ScheduledDelegate.RunState.Waiting; + + private const double filter_delay = 250; + + private ScheduledDelegate? filterDebounce; + + private void criteriaChanged(FilterCriteria criteria) + { + filterDebounce?.Cancel(); + + // The first filter needs to be applied immediately as this triggers the initial carousel load. + bool isFirstFilter = filterDebounce == null; + + // Criteria change may have included a ruleset change which made the current selection invalid. + bool isSelectionValid = checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo); + + filterDebounce = Scheduler.AddDelayed(() => carousel.Filter(criteria, !isSelectionValid), isFirstFilter || !isSelectionValid ? 0 : filter_delay); + } + + private void newItemsPresented(IEnumerable carouselItems) + { + if (carousel.Criteria == null) + return; + + CarouselItemsPresented = true; + + int count = carousel.MatchedBeatmapsCount; + + updateNoResultsPlaceholder(); + + // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 + // but also in this case we want support for formatting a number within a string). + filterControl.StatusText = count != 1 ? $"{count:#,0} matches" : $"{count:#,0} match"; + + // If there's already a selection update in progress, let's not interrupt it. + // Interrupting could cause the debounce interval to be reduced. + // + // `ensureGlobalBeatmapValid` is run post-selection which will resolve any pending incompatibilities (see `Beatmap` bindable callback). + if (debounceQueuedSelection == null) + ensureGlobalBeatmapValid(); + + updateWedgeVisibility(); + } + + private void updateNoResultsPlaceholder() + { + int count = carousel.MatchedBeatmapsCount; + + if (count == 0) + { + if (noResultsPlaceholder.State.Value == Visibility.Hidden) + { + // Duck audio temporarily when the no results placeholder becomes visible. + // + // Temporary ducking makes it easier to avoid scenarios where the ducking interacts badly + // with other global UI components (like overlays). + music.DuckMomentarily(400, new DuckParameters + { + DuckVolumeTo = 1, + DuckCutoffTo = 500, + DuckDuration = 250, + RestoreDuration = 2000, + }); + } + + noResultsPlaceholder.Show(); + noResultsPlaceholder.Filter = carousel.Criteria!; + + rightGradientBackground.ResizeWidthTo(3, 1000, Easing.OutPow10); + } + else + { + noResultsPlaceholder.Hide(); + + rightGradientBackground.ResizeWidthTo(1, 400, Easing.OutPow10); + } + } + + #endregion + + #region Input + + private ScheduledDelegate? revealingBackground; + + private GridContainer mainGridContainer = null!; + + protected override bool OnMouseDown(MouseDownEvent e) + { + var containingInputManager = GetContainingInputManager(); + + // I don't know why this works, but it does. + // If the carousel panels are hovered, hovered no longer contains the screen. + // Maybe there's a better way of doing this, but I couldn't immediately find a good setup. + bool mouseDownPriority = containingInputManager!.HoveredDrawables.Contains(this); + + // Touch input synthesises right clicks, which allow absolute scroll of the carousel. + // For simplicity, disable this functionality on mobile. + bool isTouchInput = e.CurrentState.Mouse.LastSource is ISourcedFromTouch; + + if (!carousel.AbsoluteScrolling && !isTouchInput && mouseDownPriority && revealingBackground == null) + { + revealingBackground = Scheduler.AddDelayed(() => + { + if (containingInputManager.DraggedDrawable != null) + { + revealingBackground = null; + return; + } + + mainContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint); + mainContent.ScaleTo(1.2f, 600, Easing.OutQuint); + mainContent.FadeOut(200, Easing.OutQuint); + + skinnableContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint); + skinnableContent.ScaleTo(1.2f, 600, Easing.OutQuint); + skinnableContent.FadeOut(200, Easing.OutQuint); + + updateBackgroundDim(); + + Footer?.Hide(); + }, 200); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + restoreBackground(); + base.OnMouseUp(e); + } + + private void restoreBackground() + { + if (revealingBackground == null) + return; + + if (revealingBackground.State == ScheduledDelegate.RunState.Complete) + { + mainContent.ResizeWidthTo(1f, 500, Easing.OutQuint); + mainContent.ScaleTo(1, 500, Easing.OutQuint); + mainContent.FadeIn(500, Easing.OutQuint); + + skinnableContent.ResizeWidthTo(1f, 500, Easing.OutQuint); + skinnableContent.ScaleTo(1, 500, Easing.OutQuint); + skinnableContent.FadeIn(500, Easing.OutQuint); + + Footer?.Show(); + } + + revealingBackground.Cancel(); + revealingBackground = null; + + updateBackgroundDim(); + } + + public virtual bool OnPressed(KeyBindingPressEvent e) + { + if (!this.IsCurrentScreen()) return false; + + if (game == null) + return false; + + var flattenedMods = ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value)); + + switch (e.Action) + { + case GlobalAction.Select: + // in most circumstances this is handled already by the carousel itself, but there are cases where it will not be. + // one of which is filtering out all visible beatmaps and attempting to start gameplay. + // in that case, users still expect a `Select` press to advance to gameplay anyway, using the ambient selected beatmap if there is one, + // which matches the behaviour resulting from clicking the osu! cookie in that scenario. + SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); + return true; + + case GlobalAction.IncreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(0.05, flattenedMods); + + case GlobalAction.DecreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(-0.05, flattenedMods); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) return false; + + switch (e.Key) + { + case Key.Delete: + if (e.ShiftPressed) + { + if (!Beatmap.IsDefault) + Delete(Beatmap.Value.BeatmapSetInfo); + return true; + } + + break; + } + + return base.OnKeyDown(e); + } + + #endregion + + #region Online lookups + + public enum BeatmapSetLookupStatus + { + InProgress, + Completed, + } + + public class BeatmapSetLookupResult + { + public BeatmapSetLookupStatus Status { get; } + public APIBeatmapSet? Result { get; } + + private BeatmapSetLookupResult(BeatmapSetLookupStatus status, APIBeatmapSet? result) + { + Status = status; + Result = result; + } + + public static BeatmapSetLookupResult InProgress() => new BeatmapSetLookupResult(BeatmapSetLookupStatus.InProgress, null); + public static BeatmapSetLookupResult Completed(APIBeatmapSet? beatmapSet) => new BeatmapSetLookupResult(BeatmapSetLookupStatus.Completed, beatmapSet); + } + + /// + /// Result of the latest online beatmap set lookup. + /// Note that this being or is different from + /// being a with a of null. + /// The former indicates a lookup never occurring or being in progress, while the latter indicates a completed lookup with no result. + /// + [Cached(typeof(IBindable))] + private readonly Bindable lastLookupResult = new Bindable(); + + private CancellationTokenSource? onlineLookupCancellation; + private Task? currentOnlineLookup; + + private void fetchOnlineInfo(bool force = false) + { + var beatmapSetInfo = Beatmap.Value.BeatmapSetInfo; + + if (lastLookupResult.Value?.Result?.OnlineID == beatmapSetInfo.OnlineID && !force) + return; + + onlineLookupCancellation?.Cancel(); + onlineLookupCancellation = null; + + if (beatmapSetInfo.OnlineID < 0) + { + lastLookupResult.Value = BeatmapSetLookupResult.Completed(null); + return; + } + + lastLookupResult.Value = BeatmapSetLookupResult.InProgress(); + onlineLookupCancellation = new CancellationTokenSource(); + currentOnlineLookup = onlineLookupSource.GetBeatmapSetAsync(beatmapSetInfo.OnlineID, onlineLookupCancellation.Token); + currentOnlineLookup.ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(t.GetResultSafely())); + + if (t.Exception != null) + { + Logger.Log($"Error when fetching online beatmap set: {t.Exception}", LoggingTarget.Network); + Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(null)); + } + }); + } + + #endregion + + #region Implementation of ISongSelect + + void ISongSelect.Search(string query) => filterControl.Search(query); + + void ISongSelect.PresentScore(ScoreInfo score) + { + Debug.Assert(Beatmap.Value.BeatmapInfo.Equals(score.BeatmapInfo)); + Debug.Assert(Ruleset.Value.Equals(score.Ruleset)); + + this.Push(new SoloResultsScreen(score)); + } + + #endregion + + #region IHandlePresentBeatmap + + void IHandlePresentBeatmap.PresentBeatmap(WorkingBeatmap workingBeatmap, RulesetInfo ruleset) + { + cancelDebounceSelection(); + + var beatmapInfo = workingBeatmap.BeatmapInfo; + + // Don't change the local ruleset if the user is on another ruleset and is showing converted beatmaps. + // Eventually we probably want to check whether conversion is actually possible for the current ruleset. + bool requiresRulesetSwitch = !beatmapInfo.Ruleset.Equals(Ruleset.Value) + && (beatmapInfo.Ruleset.OnlineID > 0 || !showConvertedBeatmaps.Value); + + if (requiresRulesetSwitch) + { + Ruleset.Value = beatmapInfo.Ruleset; + Beatmap.Value = workingBeatmap; + + Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} ruleset {beatmapInfo.Ruleset}"); + } + else + { + Beatmap.Value = workingBeatmap; + + Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} (maintaining ruleset)"); + } + } + + #endregion + + #region Beatmap management + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public virtual IEnumerable GetForwardActions(BeatmapInfo beatmap) + { + yield return new OsuMenuItem(GlobalActionKeyBindingStrings.Select, MenuItemType.Highlighted, () => SelectAndRun(beatmap, OnStart)) + { + Icon = FontAwesome.Solid.Check + }; + + yield return new OsuMenuItemSpacer(); + + if (beatmap.OnlineID > 0) + { + yield return new OsuMenuItem(CommonStrings.Details, MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineID)); + + if (beatmap.GetOnlineURL(api, Ruleset.Value) is string url) + yield return new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => (game as OsuGame)?.CopyToClipboard(url)); + } + + yield return new OsuMenuItemSpacer(); + + foreach (var i in CreateCollectionMenuActions(beatmap)) + yield return i; + } + + protected IEnumerable CreateCollectionMenuActions(BeatmapInfo beatmap) + { + var collectionItems = realm.Realm.All() + .OrderBy(c => c.Name) + .AsEnumerable() + .Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast().ToList(); + + collectionItems.Add(new OsuMenuItem(CommonStrings.Manage, MenuItemType.Standard, () => manageCollectionsDialog?.Show())); + + yield return new OsuMenuItem(CommonStrings.Collections) { Items = collectionItems }; + } + + public void ManageCollections() => collectionsDialog?.Show(); + + public void Delete(BeatmapSetInfo beatmapSet) => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet)); + + public void RestoreAllHidden(BeatmapSetInfo beatmapSet) + { + foreach (var b in beatmapSet.Beatmaps) + beatmaps.Restore(b); + } + + #endregion + } +} diff --git a/osu.Game/Screens/SelectV2/SongSelectV2.cs b/osu.Game/Screens/SelectV2/SongSelectV2.cs deleted file mode 100644 index 2f9667793f..0000000000 --- a/osu.Game/Screens/SelectV2/SongSelectV2.cs +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Screens; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Screens.Footer; -using osu.Game.Screens.Menu; -using osu.Game.Screens.Play; -using osu.Game.Screens.SelectV2.Footer; - -namespace osu.Game.Screens.SelectV2 -{ - /// - /// This screen is intended to house all components introduced in the new song select design to add transitions and examine the overall look. - /// This will be gradually built upon and ultimately replace once everything is in place. - /// - public partial class SongSelectV2 : OsuScreen - { - private const float logo_scale = 0.4f; - - private readonly ModSelectOverlay modSelectOverlay = new SoloModSelectOverlay(); - - [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); - - public override bool ShowFooter => true; - - [Resolved] - private OsuLogo? logo { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - AddRangeInternal(new Drawable[] - { - modSelectOverlay, - }); - } - - public override IReadOnlyList CreateFooterButtons() => new ScreenFooterButton[] - { - new ScreenFooterButtonMods(modSelectOverlay) { Current = Mods }, - new ScreenFooterButtonRandom(), - new ScreenFooterButtonOptions(), - }; - - protected override void LoadComplete() - { - base.LoadComplete(); - - modSelectOverlay.State.BindValueChanged(v => - { - logo?.ScaleTo(v.NewValue == Visibility.Visible ? 0f : logo_scale, 400, Easing.OutQuint) - .FadeTo(v.NewValue == Visibility.Visible ? 0f : 1f, 200, Easing.OutQuint); - }, true); - } - - public override void OnEntering(ScreenTransitionEvent e) - { - this.FadeIn(); - - modSelectOverlay.SelectedMods.BindTo(Mods); - - base.OnEntering(e); - } - - public override void OnResuming(ScreenTransitionEvent e) - { - this.FadeIn(); - - // required due to https://github.com/ppy/osu-framework/issues/3218 - modSelectOverlay.SelectedMods.Disabled = false; - modSelectOverlay.SelectedMods.BindTo(Mods); - - base.OnResuming(e); - } - - public override void OnSuspending(ScreenTransitionEvent e) - { - this.Delay(400).FadeOut(); - - modSelectOverlay.SelectedMods.UnbindFrom(Mods); - - base.OnSuspending(e); - } - - public override bool OnExiting(ScreenExitEvent e) - { - this.Delay(400).FadeOut(); - return base.OnExiting(e); - } - - protected override void LogoArriving(OsuLogo logo, bool resuming) - { - base.LogoArriving(logo, resuming); - - if (logo.Alpha > 0.8f) - Footer?.StartTrackingLogo(logo, 400, Easing.OutQuint); - else - { - logo.Hide(); - logo.ScaleTo(0.2f); - Footer?.StartTrackingLogo(logo); - } - - logo.FadeIn(240, Easing.OutQuint); - logo.ScaleTo(logo_scale, 240, Easing.OutQuint); - - logo.Action = () => - { - this.Push(new PlayerLoaderV2(() => new SoloPlayer())); - return false; - }; - } - - protected override void LogoSuspending(OsuLogo logo) - { - base.LogoSuspending(logo); - Footer?.StopTrackingLogo(); - } - - protected override void LogoExiting(OsuLogo logo) - { - base.LogoExiting(logo); - Scheduler.AddDelayed(() => Footer?.StopTrackingLogo(), 120); - logo.ScaleTo(0.2f, 120, Easing.Out); - logo.FadeOut(120, Easing.Out); - } - - private partial class SoloModSelectOverlay : UserModSelectOverlay - { - protected override bool ShowPresets => true; - } - - private partial class PlayerLoaderV2 : PlayerLoader - { - public override bool ShowFooter => true; - - public PlayerLoaderV2(Func createPlayer) - : base(createPlayer) - { - } - } - } -} diff --git a/osu.Game/Screens/SelectV2/Wedge/DifficultyNameContent.cs b/osu.Game/Screens/SelectV2/Wedge/DifficultyNameContent.cs deleted file mode 100644 index 4a3dc34cf9..0000000000 --- a/osu.Game/Screens/SelectV2/Wedge/DifficultyNameContent.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -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.Localisation; -using osu.Game.Overlays; - -namespace osu.Game.Screens.SelectV2.Wedge -{ - public abstract partial class DifficultyNameContent : CompositeDrawable - { - protected OsuSpriteText DifficultyName = null!; - private OsuSpriteText mappedByLabel = null!; - protected OsuHoverContainer MapperLink = null!; - protected OsuSpriteText MapperName = null!; - - protected DifficultyNameContent() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - DifficultyName = new TruncatingSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold), - }, - mappedByLabel = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - // TODO: better null display? beatmap carousel panels also just show this text currently. - Text = " mapped by ", - Font = OsuFont.GetFont(size: 14), - }, - // This is not a `LinkFlowContainer` as there are single-frame layout issues when Update() - // is being used for layout, see https://github.com/ppy/osu-framework/issues/3369. - MapperLink = new MapperLinkContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - AutoSizeAxes = Axes.Both, - Child = MapperName = new OsuSpriteText - { - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 14), - } - }, - } - }; - } - - protected override void Update() - { - base.Update(); - - // truncate difficulty name when width exceeds bounds, prioritizing mapper name display - DifficultyName.MaxWidth = Math.Max(DrawWidth - mappedByLabel.DrawWidth - - MapperName.DrawWidth, 0); - } - - private partial class MapperLinkContainer : OsuHoverContainer - { - [BackgroundDependencyLoader] - private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) - { - TooltipText = ContextMenuStrings.ViewProfile; - IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; - } - } - } -} diff --git a/osu.Game/Screens/SelectV2/Wedge/LocalDifficultyNameContent.cs b/osu.Game/Screens/SelectV2/Wedge/LocalDifficultyNameContent.cs deleted file mode 100644 index 66f8cb02b2..0000000000 --- a/osu.Game/Screens/SelectV2/Wedge/LocalDifficultyNameContent.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Beatmaps; -using osu.Game.Online; -using osu.Game.Online.Chat; - -namespace osu.Game.Screens.SelectV2.Wedge -{ - public partial class LocalDifficultyNameContent : DifficultyNameContent - { - [Resolved] - private IBindable beatmap { get; set; } = null!; - - [Resolved] - private ILinkHandler? linkHandler { get; set; } - - protected override void LoadComplete() - { - base.LoadComplete(); - - beatmap.BindValueChanged(b => - { - DifficultyName.Text = b.NewValue.BeatmapInfo.DifficultyName; - - // TODO: should be the mapper of the guest difficulty, but that isn't stored correctly yet (see https://github.com/ppy/osu/issues/12965) - MapperName.Text = b.NewValue.Metadata.Author.Username; - MapperLink.Action = () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, b.NewValue.Metadata.Author)); - }, true); - } - } -} diff --git a/osu.Game/Screens/SelectV2/WedgeBackground.cs b/osu.Game/Screens/SelectV2/WedgeBackground.cs new file mode 100644 index 0000000000..3fa21beee2 --- /dev/null +++ b/osu.Game/Screens/SelectV2/WedgeBackground.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Overlays; + +namespace osu.Game.Screens.SelectV2 +{ + internal sealed partial class WedgeBackground : InputBlockingContainer + { + public float StartAlpha { get; init; } = 0.9f; + + public float FinalAlpha { get; init; } = 0.6f; + + public float WidthForGradient { get; init; } = 0.3f; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Width = 0.6f, + Alpha = 0.5f, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background2, colourProvider.Background2.Opacity(0)), + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Width = 1 - WidthForGradient, + Colour = colourProvider.Background5.Opacity(StartAlpha), + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + Width = WidthForGradient, + Colour = ColourInfo.GradientHorizontal(colourProvider.Background5.Opacity(StartAlpha), colourProvider.Background5.Opacity(FinalAlpha)), + }, + }; + } + } +} diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index ddc638b7c5..84b5889751 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; using osu.Game.Online.Spectator; using osu.Game.Replays; using osu.Game.Rulesets; @@ -38,6 +39,9 @@ namespace osu.Game.Screens.Spectate [Resolved] private SpectatorClient spectatorClient { get; set; } = null!; + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; @@ -50,6 +54,7 @@ namespace osu.Game.Screens.Spectate private readonly Dictionary gameplayStates = new Dictionary(); private IDisposable? realmSubscription; + private IDisposable? userWatchToken; /// /// Creates a new . @@ -64,6 +69,8 @@ namespace osu.Game.Screens.Spectate { base.LoadComplete(); + userWatchToken = metadataClient.BeginWatchingUserPresence(); + userLookupCache.GetUsersAsync(users.ToArray()).ContinueWith(task => Schedule(() => { var foundUsers = task.GetResultSafely(); @@ -282,6 +289,7 @@ namespace osu.Game.Screens.Spectate } realmSubscription?.Dispose(); + userWatchToken?.Dispose(); } } } diff --git a/osu.Game/Screens/Utility/CircleGameplay.cs b/osu.Game/Screens/Utility/CircleGameplay.cs index 1f970c5121..c5c4d7d5b2 100644 --- a/osu.Game/Screens/Utility/CircleGameplay.cs +++ b/osu.Game/Screens/Utility/CircleGameplay.cs @@ -201,8 +201,8 @@ namespace osu.Game.Screens.Utility { double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); - approach.Scale = new Vector2(1 + 4 * (float)MathHelper.Clamp((HitTime - Clock.CurrentTime) / preempt, 0, 100)); - Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + approach.Scale = new Vector2(1 + 4 * (float)Math.Clamp((HitTime - Clock.CurrentTime) / preempt, 0, 100)); + Alpha = (float)Math.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); if (Clock.CurrentTime > HitTime + duration) Expire(); @@ -226,7 +226,7 @@ namespace osu.Game.Screens.Utility HitEvent = new HitEvent(Clock.CurrentTime - HitTime, 1.0, HitResult.Good, new HitObject { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), }, null, null); Hit?.Invoke(HitEvent.Value); diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs index dcfcf602bf..ef1b848945 100644 --- a/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs +++ b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; @@ -55,22 +56,22 @@ namespace osu.Game.Screens.Utility.SampleComponents { case Key.F: case Key.Up: - box.Y = MathHelper.Clamp(box.Y - movementAmount, 0.1f, 0.9f); + box.Y = Math.Clamp(box.Y - movementAmount, 0.1f, 0.9f); break; case Key.J: case Key.Down: - box.Y = MathHelper.Clamp(box.Y + movementAmount, 0.1f, 0.9f); + box.Y = Math.Clamp(box.Y + movementAmount, 0.1f, 0.9f); break; case Key.Z: case Key.Left: - box.X = MathHelper.Clamp(box.X - movementAmount, 0.1f, 0.9f); + box.X = Math.Clamp(box.X - movementAmount, 0.1f, 0.9f); break; case Key.X: case Key.Right: - box.X = MathHelper.Clamp(box.X + movementAmount, 0.1f, 0.9f); + box.X = Math.Clamp(box.X + movementAmount, 0.1f, 0.9f); break; } } diff --git a/osu.Game/Screens/Utility/ScrollingGameplay.cs b/osu.Game/Screens/Utility/ScrollingGameplay.cs index 5038c53b4a..3e0969b625 100644 --- a/osu.Game/Screens/Utility/ScrollingGameplay.cs +++ b/osu.Game/Screens/Utility/ScrollingGameplay.cs @@ -165,7 +165,7 @@ namespace osu.Game.Screens.Utility { double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); - Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + Alpha = (float)Math.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); Y = judgement_position - (float)((HitTime - Clock.CurrentTime) / preempt); if (Clock.CurrentTime > HitTime + duration) @@ -188,7 +188,7 @@ namespace osu.Game.Screens.Utility HitEvent = new HitEvent(Clock.CurrentTime - HitTime, 1.0, HitResult.Good, new HitObject { - HitWindows = new HitWindows(), + HitWindows = new DefaultHitWindows(), }, null, null); Hit?.Invoke(HitEvent.Value); diff --git a/osu.Game/Seasonal/IntroChristmas.cs b/osu.Game/Seasonal/IntroChristmas.cs new file mode 100644 index 0000000000..ac3286f277 --- /dev/null +++ b/osu.Game/Seasonal/IntroChristmas.cs @@ -0,0 +1,332 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Textures; +using osu.Framework.Screens; +using osu.Framework.Timing; +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.Screens.Menu; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + public partial class IntroChristmas : IntroScreen + { + // nekodex - circle the halls + public const string CHRISTMAS_BEATMAP_SET_HASH = "7e26183e72a496f672c3a21292e6b469fdecd084d31c259ea10a31df5b46cd77"; + + protected override string BeatmapHash => CHRISTMAS_BEATMAP_SET_HASH; + + protected override string BeatmapFile => "christmas2024.osz"; + + private const double beat_length = 60000 / 172.0; + private const double offset = 5924; + + protected override string SeeyaSampleName => "Intro/Welcome/seeya"; + + private TrianglesIntroSequence intro = null!; + + public IntroChristmas(Func? createNextScreen = null) + : base(createNextScreen) + { + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (!resuming) + { + PrepareMenuLoad(); + + var decouplingClock = new DecouplingFramedClock(UsingThemedIntro ? Track : null); + + LoadComponentAsync(intro = new TrianglesIntroSequence(logo, () => FadeInBackground()) + { + RelativeSizeAxes = Axes.Both, + Clock = new InterpolatingFramedClock(decouplingClock), + LoadMenu = LoadMenu + }, _ => + { + AddInternal(intro); + + // There is a chance that the intro timed out before being displayed, and this scheduled callback could + // happen during the outro rather than intro. + // In such a scenario, we don't want to play the intro sample, nor attempt to start the intro track + // (that may have already been since disposed by MusicController). + if (DidLoadMenu) + return; + + // If the user has requested no theme, fallback to the same intro voice and delay as IntroCircles. + // The triangles intro voice and theme are combined which makes it impossible to use. + StartTrack(); + + // no-op for the case of themed intro, no harm in calling for both scenarios as a safety measure. + decouplingClock.Start(); + }); + } + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + // important as there is a clock attached to a track which will likely be disposed before returning to this screen. + intro.Expire(); + } + + private partial class TrianglesIntroSequence : CompositeDrawable + { + private readonly OsuLogo logo; + private readonly Action showBackgroundAction; + private OsuSpriteText welcomeText = null!; + + private Container logoContainerSecondary = null!; + private LazerLogo lazerLogo = null!; + + private Drawable triangles = null!; + + public Action LoadMenu = null!; + + [Resolved] + private OsuGameBase game { get; set; } = null!; + + public TrianglesIntroSequence(OsuLogo logo, Action showBackgroundAction) + { + this.logo = logo; + this.showBackgroundAction = showBackgroundAction; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new[] + { + welcomeText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding { Bottom = 10 }, + Font = OsuFont.GetFont(weight: FontWeight.Light, size: 42), + Alpha = 1, + Spacing = new Vector2(5), + }, + logoContainerSecondary = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = lazerLogo = new LazerLogo + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }, + triangles = new CircularContainer + { + Alpha = 0, + Masking = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(960), + Child = new GlitchingTriangles + { + RelativeSizeAxes = Axes.Both, + }, + } + }; + } + + private static double getTimeForBeat(int beat) => offset + beat_length * beat; + + protected override void LoadComplete() + { + base.LoadComplete(); + + lazerLogo.Hide(); + + using (BeginAbsoluteSequence(0)) + { + using (BeginDelayedSequence(getTimeForBeat(-16))) + welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to osu!"); + + using (BeginDelayedSequence(getTimeForBeat(-15))) + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + + using (BeginDelayedSequence(getTimeForBeat(-14))) + welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to osu!"); + + using (BeginDelayedSequence(getTimeForBeat(-13))) + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + + using (BeginDelayedSequence(getTimeForBeat(-12))) + welcomeText.FadeIn().OnComplete(t => t.Text = "merry christmas!"); + + using (BeginDelayedSequence(getTimeForBeat(-11))) + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + + using (BeginDelayedSequence(getTimeForBeat(-10))) + welcomeText.FadeIn().OnComplete(t => t.Text = "merry osumas!"); + + using (BeginDelayedSequence(getTimeForBeat(-9))) + { + welcomeText.FadeIn().OnComplete(t => t.Text = ""); + } + + lazerLogo.Scale = new Vector2(0.2f); + triangles.Scale = new Vector2(0.2f); + + for (int i = 0; i < 8; i++) + { + using (BeginDelayedSequence(getTimeForBeat(-8 + i))) + { + triangles.FadeIn(); + + lazerLogo.ScaleTo(new Vector2(0.2f + (i + 1) / 8f * 0.3f), beat_length * 1, Easing.OutQuint); + triangles.ScaleTo(new Vector2(0.2f + (i + 1) / 8f * 0.3f), beat_length * 1, Easing.OutQuint); + lazerLogo.FadeTo((i + 1) * 0.06f); + lazerLogo.TransformTo(nameof(LazerLogo.Progress), (i + 1) / 10f); + } + } + + GameWideFlash flash = new GameWideFlash(); + + using (BeginDelayedSequence(getTimeForBeat(-2))) + { + lazerLogo.FadeIn().OnComplete(_ => game.Add(flash)); + } + + flash.FadeInCompleted = () => + { + logoContainerSecondary.Remove(lazerLogo, true); + triangles.FadeOut(); + logo.FadeIn(); + showBackgroundAction(); + LoadMenu(); + }; + } + } + + private partial class GameWideFlash : Box + { + public Action? FadeInCompleted; + + public GameWideFlash() + { + Colour = Color4.White; + RelativeSizeAxes = Axes.Both; + Blending = BlendingParameters.Additive; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Alpha = 0; + + this.FadeTo(0.5f, beat_length * 2, Easing.In) + .OnComplete(_ => FadeInCompleted?.Invoke()); + + this.Delay(beat_length * 2) + .Then() + .FadeOutFromOne(3000, Easing.OutQuint); + } + } + + private partial class LazerLogo : CompositeDrawable + { + private LogoAnimation highlight = null!; + private LogoAnimation background = null!; + + public float Progress + { + get => background.AnimationProgress; + set + { + background.AnimationProgress = value; + highlight.AnimationProgress = value; + } + } + + public LazerLogo() + { + Size = new Vector2(960); + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + InternalChildren = new Drawable[] + { + highlight = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-highlight"), + Colour = Color4.White, + }, + background = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }, + }; + } + } + + private partial class GlitchingTriangles : BeatSyncedContainer + { + private int beatsHandled; + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + Divisor = beatsHandled < 4 ? 1 : 4; + + for (int i = 0; i < (beatsHandled + 1); i++) + { + float angle = (float)(RNG.NextDouble() * 2 * Math.PI); + float randomRadius = (float)(Math.Sqrt(RNG.NextDouble())); + + float x = 0.5f + 0.5f * randomRadius * (float)Math.Cos(angle); + float y = 0.5f + 0.5f * randomRadius * (float)Math.Sin(angle); + + Color4 christmasColour = RNG.NextBool() ? SeasonalUIConfig.PRIMARY_COLOUR_1 : SeasonalUIConfig.PRIMARY_COLOUR_2; + + Drawable triangle = new Triangle + { + Size = new Vector2(RNG.NextSingle() + 1.2f) * 80, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Both, + Position = new Vector2(x, y), + Colour = christmasColour + }; + + if (beatsHandled >= 10) + triangle.Blending = BlendingParameters.Additive; + + AddInternal(triangle); + triangle + .ScaleTo(0.9f) + .ScaleTo(1, beat_length / 2, Easing.Out); + triangle.FadeInFromZero(100, Easing.OutQuint); + } + + beatsHandled += 1; + } + } + } + } +} diff --git a/osu.Game/Seasonal/MainMenuSeasonalLighting.cs b/osu.Game/Seasonal/MainMenuSeasonalLighting.cs new file mode 100644 index 0000000000..30ad7acefe --- /dev/null +++ b/osu.Game/Seasonal/MainMenuSeasonalLighting.cs @@ -0,0 +1,206 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + public partial class MainMenuSeasonalLighting : CompositeDrawable + { + private IBindable working = null!; + + private InterpolatingFramedClock? beatmapClock; + + private List hitObjects = null!; + + private RulesetInfo? osuRuleset; + + private int? lastObjectIndex; + + public MainMenuSeasonalLighting() + { + // match beatmap playfield + RelativeChildSize = new Vector2(512, 384); + + RelativeSizeAxes = Axes.X; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(IBindable working, RulesetStore rulesets) + { + // operate in osu! ruleset to keep things simple for now. + osuRuleset = rulesets.GetRuleset(0); + + this.working = working.GetBoundCopy(); + this.working.BindValueChanged(_ => Scheduler.AddOnce(updateBeatmap), true); + } + + private void updateBeatmap() + { + lastObjectIndex = null; + + if (osuRuleset == null) + { + beatmapClock = new InterpolatingFramedClock(Clock); + hitObjects = new List(); + return; + } + + // Intentionally maintain separately so the lighting is not in audio clock space (it shouldn't rewind etc.) + beatmapClock = new InterpolatingFramedClock(new FramedClock(working.Value.Track)); + + hitObjects = working.Value + .GetPlayableBeatmap(osuRuleset) + .HitObjects + .SelectMany(h => h.NestedHitObjects.Prepend(h)) + .OrderBy(h => h.StartTime) + .ToList(); + } + + protected override void Update() + { + base.Update(); + + if (osuRuleset == null || beatmapClock == null) + return; + + Height = DrawWidth / 16 * 10; + + beatmapClock.ProcessFrame(); + + // intentionally slightly early since we are doing fades on the lighting. + double time = beatmapClock.CurrentTime + 50; + + // handle seeks or OOB by skipping to current. + if (lastObjectIndex == null || lastObjectIndex >= hitObjects.Count || (lastObjectIndex >= 0 && hitObjects[lastObjectIndex.Value].StartTime > time) + || Math.Abs(beatmapClock.ElapsedFrameTime) > 500) + lastObjectIndex = hitObjects.Count(h => h.StartTime < time) - 1; + + while (lastObjectIndex < hitObjects.Count - 1) + { + var h = hitObjects[lastObjectIndex.Value + 1]; + + if (h.StartTime > time) + break; + + // Don't add lighting if the game is running too slow. + if (Clock.ElapsedFrameTime < 20) + addLight(h); + + lastObjectIndex++; + } + } + + private void addLight(HitObject h) + { + var light = new Light + { + RelativePositionAxes = Axes.Both, + Position = ((IHasPosition)h).Position + }; + + AddInternal(light); + + if (h.GetType().Name.Contains("Tick")) + { + light.Colour = SeasonalUIConfig.AMBIENT_COLOUR_1; + light.Scale = new Vector2(0.5f); + light + .FadeInFromZero(250) + .Then() + .FadeOutFromOne(1000, Easing.Out); + + light.MoveToOffset(new Vector2(RNG.Next(-20, 20), RNG.Next(-20, 20)), 1400, Easing.Out); + } + else + { + // default are green + Color4 col = SeasonalUIConfig.PRIMARY_COLOUR_2; + + // whistles are red + if (h.Samples.Any(s => s.Name == HitSampleInfo.HIT_WHISTLE)) + col = SeasonalUIConfig.PRIMARY_COLOUR_1; + // clap is third ambient (yellow) colour + else if (h.Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)) + col = SeasonalUIConfig.AMBIENT_COLOUR_1; + + light.Colour = col; + + // finish results in larger lighting + if (h.Samples.Any(s => s.Name == HitSampleInfo.HIT_FINISH)) + light.Scale = new Vector2(3); + + light + .FadeInFromZero(150) + .Then() + .FadeOutFromOne(1000, Easing.In); + } + + light.Expire(); + } + + private partial class Light : CompositeDrawable + { + private readonly Circle circle; + + public new Color4 Colour + { + set + { + circle.Colour = value.Darken(0.8f); + circle.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = value, + Radius = 80, + }; + } + } + + public Light() + { + InternalChildren = new Drawable[] + { + circle = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12), + Colour = SeasonalUIConfig.AMBIENT_COLOUR_1, + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = SeasonalUIConfig.AMBIENT_COLOUR_2, + Radius = 80, + } + } + }; + + Origin = Anchor.Centre; + Alpha = 0.5f; + } + } + } +} diff --git a/osu.Game/Seasonal/OsuLogoChristmas.cs b/osu.Game/Seasonal/OsuLogoChristmas.cs new file mode 100644 index 0000000000..8975a69c32 --- /dev/null +++ b/osu.Game/Seasonal/OsuLogoChristmas.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Screens.Menu; +using osuTK; + +namespace osu.Game.Seasonal +{ + public partial class OsuLogoChristmas : OsuLogo + { + protected override double BeatSampleVariance => 0.02; + + private Sprite? hat; + + private bool hasHat; + + protected override MenuLogoVisualisation CreateMenuLogoVisualisation() => new SeasonalMenuLogoVisualisation(); + + [BackgroundDependencyLoader] + private void load(TextureStore textures, AudioManager audio) + { + LogoElements.Add(hat = new Sprite + { + BypassAutoSizeAxes = Axes.Both, + Alpha = 0, + Origin = Anchor.BottomCentre, + Scale = new Vector2(-1, 1), + Texture = textures.Get(@"Menu/hat"), + }); + + // override base samples with our preferred ones. + SampleDownbeat = SampleBeat = audio.Samples.Get(@"Menu/osu-logo-heartbeat-bell"); + } + + protected override void Update() + { + base.Update(); + updateHat(); + } + + private void updateHat() + { + if (hat == null) + return; + + bool shouldHat = DrawWidth * Scale.X < 400; + + if (shouldHat != hasHat) + { + hasHat = shouldHat; + + if (hasHat) + { + hat.Delay(400) + .Then() + .MoveTo(new Vector2(120, 160)) + .RotateTo(0) + .RotateTo(-20, 500, Easing.OutQuint) + .FadeIn(250, Easing.OutQuint); + } + else + { + hat.Delay(100) + .Then() + .MoveToOffset(new Vector2(0, -5), 500, Easing.OutQuint) + .FadeOut(500, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game/Seasonal/SeasonalMenuLogoVisualisation.cs b/osu.Game/Seasonal/SeasonalMenuLogoVisualisation.cs new file mode 100644 index 0000000000..f00da3fe7e --- /dev/null +++ b/osu.Game/Seasonal/SeasonalMenuLogoVisualisation.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.Screens.Menu; + +namespace osu.Game.Seasonal +{ + internal partial class SeasonalMenuLogoVisualisation : MenuLogoVisualisation + { + protected override void UpdateColour() => Colour = SeasonalUIConfig.AMBIENT_COLOUR_1; + } +} diff --git a/osu.Game/Seasonal/SeasonalMenuSideFlashes.cs b/osu.Game/Seasonal/SeasonalMenuSideFlashes.cs new file mode 100644 index 0000000000..46a0a973bb --- /dev/null +++ b/osu.Game/Seasonal/SeasonalMenuSideFlashes.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Utils; +using osu.Game.Screens.Menu; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + public partial class SeasonalMenuSideFlashes : MenuSideFlashes + { + protected override bool RefreshColoursEveryFlash => true; + + protected override float Intensity => 4; + + protected override Color4 GetBaseColour() => RNG.NextBool() ? SeasonalUIConfig.PRIMARY_COLOUR_1 : SeasonalUIConfig.PRIMARY_COLOUR_2; + } +} diff --git a/osu.Game/Seasonal/SeasonalUIConfig.cs b/osu.Game/Seasonal/SeasonalUIConfig.cs new file mode 100644 index 0000000000..b894a42108 --- /dev/null +++ b/osu.Game/Seasonal/SeasonalUIConfig.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.Color4Extensions; +using osuTK.Graphics; + +namespace osu.Game.Seasonal +{ + /// + /// General configuration setting for seasonal event adjustments to the game. + /// + public static class SeasonalUIConfig + { + public static readonly bool ENABLED = false; + + public static readonly Color4 PRIMARY_COLOUR_1 = Color4Extensions.FromHex(@"D32F2F"); + + public static readonly Color4 PRIMARY_COLOUR_2 = Color4Extensions.FromHex(@"388E3C"); + + public static readonly Color4 AMBIENT_COLOUR_1 = Color4Extensions.FromHex(@"FFFFCC"); + + public static readonly Color4 AMBIENT_COLOUR_2 = Color4Extensions.FromHex(@"FFE4B5"); + } +} diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index 771d10d73b..9e8fe4f617 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -7,7 +7,6 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; @@ -110,15 +109,42 @@ namespace osu.Game.Skinning case GlobalSkinnableContainers.MainHUDComponents: if (containerLookup.Ruleset != null) { - return new Container + return new DefaultSkinComponentsContainer(container => + { + var leaderboard = container.OfType().FirstOrDefault(); + var comboCounter = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + if (leaderboard != null) + leaderboard.Position = new Vector2(36, 115); + + Vector2 pos = new Vector2(36, -66); + + if (comboCounter != null) + { + comboCounter.Position = pos; + pos -= new Vector2(0, comboCounter.DrawHeight * 1.4f + 20); + } + + if (spectatorList != null) + spectatorList.Position = pos; + }) { RelativeSizeAxes = Axes.Both, - Child = new ArgonComboCounter + Children = new Drawable[] { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Position = new Vector2(36, -66), - Scale = new Vector2(1.3f), + new DrawableGameplayLeaderboard(), + new ArgonComboCounter + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Scale = new Vector2(1.3f), + }, + new SpectatorList + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } }, }; } diff --git a/osu.Game/Skinning/Components/ArgonJudgementCounter.cs b/osu.Game/Skinning/Components/ArgonJudgementCounter.cs new file mode 100644 index 0000000000..84973aab3e --- /dev/null +++ b/osu.Game/Skinning/Components/ArgonJudgementCounter.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.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.Game.Graphics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.JudgementCounter; +using osuTK.Graphics; + +namespace osu.Game.Skinning.Components +{ + public sealed partial class ArgonJudgementCounter : VisibilityContainer + { + public readonly JudgementCount Result; + + public IBindable WireframeOpacity => textComponent.WireframeOpacity; + + public IBindable WireframeDigits { get; } = new Bindable(); + + public IBindable ShowLabel => textComponent.ShowLabel; + + private readonly ArgonCounterTextComponent textComponent; + private readonly BindableInt displayedValue = new BindableInt(); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementCounter(JudgementCount result) + { + Result = result; + + AutoSizeAxes = Axes.Both; + AddInternal(textComponent = new ArgonCounterTextComponent(Anchor.TopLeft, result.DisplayName.ToUpper())); + } + + private void updateWireframe() + { + textComponent.WireframeTemplate = new string('#', WireframeDigits.Value ?? Math.Max(2, textComponent.Text.ToString().Length)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + WireframeDigits.BindValueChanged(_ => updateWireframe()); + + displayedValue.BindTo(Result.ResultCount); + displayedValue.BindValueChanged(v => + { + textComponent.Text = v.NewValue.ToString(); + updateWireframe(); + }, true); + + var result = Result.Types.First(); + textComponent.LabelColour.Value = getJudgementColor(result); + textComponent.ShowLabel.BindValueChanged(v => textComponent.TextColour.Value = !v.NewValue ? getJudgementColor(result) : Color4.White, true); + } + + private Color4 getJudgementColor(HitResult result) + { + return result.IsBasic() ? colours.ForHitResult(result) : !result.IsBonus() ? colours.PurpleLight : colours.PurpleLighter; + } + + protected override void PopIn() => this.FadeIn(JudgementCounterDisplay.TRANSFORM_DURATION, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(); + } +} diff --git a/osu.Game/Skinning/Components/ArgonJudgementCounterDisplay.cs b/osu.Game/Skinning/Components/ArgonJudgementCounterDisplay.cs new file mode 100644 index 0000000000..885b3922f2 --- /dev/null +++ b/osu.Game/Skinning/Components/ArgonJudgementCounterDisplay.cs @@ -0,0 +1,170 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +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.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation.HUD; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.JudgementCounter; +using osuTK; + +namespace osu.Game.Skinning.Components +{ + [UsedImplicitly] + public partial class ArgonJudgementCounterDisplay : CompositeDrawable, ISerialisableDrawable + { + [Resolved] + private JudgementCountController judgementCountController { get; set; } = null!; + + [SettingSource("Wireframe opacity", "Controls the opacity of the wireframes behind the digits.")] + public BindableFloat WireframeOpacity { get; } = new BindableFloat(0.25f) + { + Precision = 0.01f, + MinValue = 0, + MaxValue = 1, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] + public Bindable ShowLabel { get; } = new BindableBool(true); + + [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.ShowMaxJudgement))] + public BindableBool ShowMaxJudgement { get; } = new BindableBool(true); + + [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayMode))] + public Bindable Mode { get; } = new Bindable(); + + [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.FlowDirection))] + public Bindable FlowDirection { get; } = new Bindable(); + + private readonly Bindable wireframeDigits = new Bindable(); + + protected FillFlowContainer CounterFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + InternalChild = CounterFlow = new FillFlowContainer + { + Direction = getFillDirection(FlowDirection.Value), + Spacing = new Vector2(16), + AutoSizeAxes = Axes.Both, + }; + + foreach (var counter in judgementCountController.Counters) + { + counter.ResultCount.BindValueChanged(_ => updateWireframeDigits()); + ArgonJudgementCounter counterComponent = new ArgonJudgementCounter(counter) + { + WireframeOpacity = { BindTarget = WireframeOpacity }, + WireframeDigits = { BindTarget = wireframeDigits }, + ShowLabel = { BindTarget = ShowLabel }, + }; + CounterFlow.Add(counterComponent); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Mode.BindValueChanged(_ => updateVisibility()); + ShowMaxJudgement.BindValueChanged(_ => updateVisibility(), true); + FlowDirection.BindValueChanged(_ => updateFlowDirection(), true); + } + + private void updateVisibility() + { + for (int i = 0; i < CounterFlow.Children.Count; i++) + { + ArgonJudgementCounter counter = CounterFlow.Children[i]; + + if (shouldBeVisible(i, counter)) + counter.Show(); + else + counter.Hide(); + } + + updateWireframeDigits(); + } + + private void updateFlowDirection() + { + CounterFlow.Direction = getFillDirection(FlowDirection.Value); + updateWireframeDigits(); + } + + private void updateWireframeDigits() + { + var visibleCounters = CounterFlow.Children.Where(counter => counter.State.Value == Visibility.Visible).ToArray(); + + if (visibleCounters.Length == 0) + return; + + wireframeDigits.Value = FlowDirection.Value == Direction.Vertical + ? Math.Max(2, visibleCounters.Max(counter => counter.Result.ResultCount.Value).ToString().Length) + : null; + } + + private bool shouldBeVisible(int index, ArgonJudgementCounter counter) + { + if (index == 0 && !ShowMaxJudgement.Value) + return false; + + var hitResult = counter.Result.Types.First(); + if (hitResult.IsBasic()) + return true; + + switch (Mode.Value) + { + case DisplayMode.Simple: + return false; + + case DisplayMode.Normal: + return !hitResult.IsBonus(); + + case DisplayMode.All: + return true; + + default: + throw new ArgumentOutOfRangeException(nameof(Mode), Mode.Value, null); + } + } + + private FillDirection getFillDirection(Direction flow) + { + switch (flow) + { + case Direction.Horizontal: + return FillDirection.Horizontal; + + case Direction.Vertical: + return FillDirection.Vertical; + + default: + throw new ArgumentOutOfRangeException(nameof(flow), flow, null); + } + } + + public enum DisplayMode + { + [LocalisableDescription(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayModeSimple))] + Simple, + + [LocalisableDescription(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayModeNormal))] + Normal, + + [LocalisableDescription(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayModeAll))] + All + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs index f1c27434fa..3935277dfb 100644 --- a/osu.Game/Skinning/Components/BeatmapAttributeText.cs +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -29,7 +28,7 @@ namespace osu.Game.Skinning.Components [UsedImplicitly] public partial class BeatmapAttributeText : FontAdjustableSkinComponent { - [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Attribute), nameof(BeatmapAttributeTextStrings.AttributeDescription))] + [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Attribute))] public Bindable Attribute { get; } = new Bindable(BeatmapAttribute.StarRating); [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Template), nameof(BeatmapAttributeTextStrings.TemplateDescription))] @@ -48,7 +47,7 @@ namespace osu.Game.Skinning.Components private BeatmapDifficultyCache difficultyCache { get; set; } = null!; private readonly OsuSpriteText text; - private IBindable? difficultyBindable; + private IBindable? difficultyBindable; private CancellationTokenSource? difficultyCancellationSource; private ModSettingChangeTracker? modSettingTracker; private StarDifficulty? starDifficulty; @@ -226,7 +225,7 @@ namespace osu.Game.Skinning.Components return computeDifficulty().ApproachRate.ToLocalisableString(@"0.##"); case BeatmapAttribute.StarRating: - return (starDifficulty?.Stars ?? 0).ToLocalisableString(@"F2"); + return (starDifficulty?.Stars ?? 0).FormatStarRating(); case BeatmapAttribute.MaxPP: return Math.Round(starDifficulty?.PerformanceAttributes?.Total ?? 0, MidpointRounding.AwayFromZero).ToLocalisableString(); @@ -237,18 +236,9 @@ namespace osu.Game.Skinning.Components BeatmapDifficulty computeDifficulty() { - BeatmapDifficulty difficulty = new BeatmapDifficulty(beatmap.Value.BeatmapInfo.Difficulty); - - foreach (var mod in mods.Value.OfType()) - mod.ApplyToDifficulty(difficulty); - - if (ruleset.Value is RulesetInfo rulesetInfo) - { - double rate = ModUtils.CalculateRateWithMods(mods.Value); - difficulty = rulesetInfo.CreateInstance().GetRateAdjustedDisplayDifficulty(difficulty, rate); - } - - return difficulty; + return ruleset.Value is RulesetInfo rulesetInfo + ? rulesetInfo.CreateInstance().GetAdjustedDisplayDifficulty(beatmap.Value.BeatmapInfo, mods.Value) + : new BeatmapDifficulty(beatmap.Value.BeatmapInfo.Difficulty); } } diff --git a/osu.Game/Skinning/Components/BoxElement.cs b/osu.Game/Skinning/Components/BoxElement.cs index 7f052a8523..ddfa1aa446 100644 --- a/osu.Game/Skinning/Components/BoxElement.cs +++ b/osu.Game/Skinning/Components/BoxElement.cs @@ -27,7 +27,7 @@ namespace osu.Game.Skinning.Components Precision = 0.01f }; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); public BoxElement() diff --git a/osu.Game/Skinning/Components/TextElement.cs b/osu.Game/Skinning/Components/TextElement.cs index 6e875c5590..a271857c03 100644 --- a/osu.Game/Skinning/Components/TextElement.cs +++ b/osu.Game/Skinning/Components/TextElement.cs @@ -15,7 +15,7 @@ namespace osu.Game.Skinning.Components [UsedImplicitly] public partial class TextElement : FontAdjustableSkinComponent { - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextElementText), nameof(SkinnableComponentStrings.TextElementTextDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextElementText))] public Bindable Text { get; } = new Bindable("Circles!"); private readonly OsuSpriteText text; diff --git a/osu.Game/Skinning/FontAdjustableSkinComponent.cs b/osu.Game/Skinning/FontAdjustableSkinComponent.cs index 0821edf7fc..eba29e9b79 100644 --- a/osu.Game/Skinning/FontAdjustableSkinComponent.cs +++ b/osu.Game/Skinning/FontAdjustableSkinComponent.cs @@ -1,13 +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 osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation.SkinComponents; +using osu.Game.Overlays.Settings; namespace osu.Game.Skinning { @@ -18,10 +21,13 @@ namespace osu.Game.Skinning { public bool UsesFixedAnchor { get; set; } - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font))] public Bindable Font { get; } = new Bindable(Typeface.Torus); - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextWeight), SettingControlType = typeof(WeightDropdown))] + public Bindable TextWeight { get; } = new Bindable(FontWeight.Regular); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour))] public BindableColour4 TextColour { get; } = new BindableColour4(Colour4.White); /// @@ -35,16 +41,69 @@ namespace osu.Game.Skinning { base.LoadComplete(); - Font.BindValueChanged(e => - { - // We only have bold weight for venera, so let's force that. - FontWeight fontWeight = e.NewValue == Typeface.Venera ? FontWeight.Bold : FontWeight.Regular; - - FontUsage f = OsuFont.GetFont(e.NewValue, weight: fontWeight); - SetFont(f); - }, true); + Font.BindValueChanged(_ => updateFont()); + TextWeight.BindValueChanged(_ => updateFont(), true); TextColour.BindValueChanged(e => SetTextColour(e.NewValue), true); } + + private void updateFont() => SetFont(OsuFont.GetFont(Font.Value, weight: TextWeight.Value)); + + private partial class WeightDropdown : SettingsDropdown + { + public FontAdjustableSkinComponent FontComponent => (FontAdjustableSkinComponent)SettingSourceObject; + protected override OsuDropdown CreateDropdown() => new DropdownControl(this); + + private new partial class DropdownControl : SettingsDropdown.DropdownControl + { + private readonly WeightDropdown settingsDropdown; + + private IBindable font = null!; + + public DropdownControl(WeightDropdown settingsDropdown) + { + this.settingsDropdown = settingsDropdown; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + font = settingsDropdown.FontComponent.Font.GetBoundCopy(); + font.BindValueChanged(_ => updateItems(), true); + } + + private void updateItems() + { + ClearItems(); + + switch (font.Value) + { + case Typeface.Venera: + AddDropdownItem(FontWeight.Light); + AddDropdownItem(FontWeight.Bold); + AddDropdownItem(FontWeight.Black); + + Current.Default = FontWeight.Bold; + + if (!Items.Contains(Current.Value)) + Current.SetDefault(); + break; + + default: + AddDropdownItem(FontWeight.Light); + AddDropdownItem(FontWeight.Regular); + AddDropdownItem(FontWeight.SemiBold); + AddDropdownItem(FontWeight.Bold); + + Current.Default = FontWeight.Regular; + + if (!Items.Contains(Current.Value)) + Current.SetDefault(); + break; + } + } + } + } } } diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 656c0e046f..e198d43be7 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -11,7 +11,6 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.IO; -using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osuTK.Graphics; @@ -90,11 +89,8 @@ namespace osu.Game.Skinning public override ISample? GetSample(ISampleInfo sampleInfo) { - if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) - { - // When no custom sample bank is provided, always fall-back to the default samples. + if (sampleInfo is HitSampleInfo hitSampleInfo && !hitSampleInfo.UseBeatmapSamples) return null; - } return base.GetSample(sampleInfo); } diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 9c06cbbfb5..0d561d6c89 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -234,14 +234,14 @@ namespace osu.Game.Skinning { Bulge(); explode.Blending = isEpic ? BlendingParameters.Additive : BlendingParameters.Inherit; - explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120); - explode.FadeOutFromOne(120); + explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120, Easing.Out); + explode.FadeOutFromOne(120, Easing.Out); } public override void Bulge() { base.Bulge(); - Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); + Main.ScaleTo(1.2f).Then().ScaleTo(0.8f, 150); } } diff --git a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs index db1f216b6e..1e6fa44e68 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs @@ -41,6 +41,7 @@ namespace osu.Game.Skinning public float LightPosition = (480 - 413) * POSITION_SCALE_FACTOR; public float ComboPosition = 111 * POSITION_SCALE_FACTOR; public float ScorePosition = 300 * POSITION_SCALE_FACTOR; + public float BarLineHeight = 1; public bool ShowJudgementLine = true; public bool KeysUnderNotes; public int LightFramePerSecond = 60; diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs index ee354de68b..b198dd3203 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -37,10 +37,7 @@ namespace osu.Game.Skinning public enum LegacyManiaSkinConfigurationLookups { ColumnWidth, - ColumnSpacing, LightImage, - LeftLineWidth, - RightLineWidth, HitPosition, ComboPosition, ScorePosition, @@ -56,10 +53,8 @@ namespace osu.Game.Skinning HoldNoteTailImage, HoldNoteBodyImage, HoldNoteLightImage, - HoldNoteLightScale, WidthForNoteHeightScale, ExplosionImage, - ExplosionScale, ColumnLineColour, JudgementLineColour, ColumnBackgroundColour, @@ -70,6 +65,9 @@ namespace osu.Game.Skinning RightStageImage, BottomStageImage, + BarLineHeight, + BarLineColour, + // ReSharper disable once InconsistentNaming Hit300g, @@ -80,6 +78,16 @@ namespace osu.Game.Skinning Hit0, KeysUnderNotes, NoteBodyStyle, - LightFramePerSecond + LightFramePerSecond, + + // The following lookup entries are not directly tied to skin.ini settings + // but are defined to simplify the process of determining such values. + + LeftColumnSpacing, + RightColumnSpacing, + LeftLineWidth, + RightLineWidth, + ExplosionScale, + HoldNoteLightScale, } } diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 09866ef237..2739743387 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -86,6 +86,10 @@ namespace osu.Game.Skinning parseArrayValue(pair.Value, currentConfig.ColumnWidth); break; + case "BarlineHeight": + currentConfig.BarLineHeight = float.Parse(pair.Value, CultureInfo.InvariantCulture); + break; + case "HitPosition": currentConfig.HitPosition = (480 - Math.Clamp(float.Parse(pair.Value, CultureInfo.InvariantCulture), 240, 480)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; break; diff --git a/osu.Game/Skinning/LegacyRankDisplay.cs b/osu.Game/Skinning/LegacyRankDisplay.cs index 70b5ed0278..64376f0dbd 100644 --- a/osu.Game/Skinning/LegacyRankDisplay.cs +++ b/osu.Game/Skinning/LegacyRankDisplay.cs @@ -2,10 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Game.Audio; +using osu.Game.Configuration; +using osu.Game.Localisation; +using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osuTK; namespace osu.Game.Skinning @@ -20,44 +26,107 @@ namespace osu.Game.Skinning [Resolved] private ISkinSource source { get; set; } = null!; - private readonly Sprite rank; + [SettingSource(typeof(DefaultRankDisplayStrings), nameof(DefaultRankDisplayStrings.PlaySamplesOnRankChange))] + public BindableBool PlaySamples { get; set; } = new BindableBool(true); + + private readonly Sprite rankDisplay; + + private SkinnableSound rankDownSample = null!; + private SkinnableSound rankUpSample = null!; + + private Bindable lastSamplePlayback = null!; + private double lastChangeTime; + + private ScoreRank? displayedRank; + + private const int time_between_changes = 1500; public LegacyRankDisplay() { AutoSizeAxes = Axes.Both; - AddInternal(rank = new Sprite + AddInternal(rankDisplay = new Sprite { Anchor = Anchor.Centre, Origin = Anchor.Centre, }); } + [BackgroundDependencyLoader] + private void load(SkinEditor? skinEditor, SessionStatics statics) + { + AddRangeInternal(new Drawable[] + { + rankDownSample = new SkinnableSound(new SampleInfo("Gameplay/rank-down")), + rankUpSample = new SkinnableSound(new SampleInfo("Gameplay/rank-up")), + }); + + if (skinEditor != null) + PlaySamples.Value = false; + + lastSamplePlayback = statics.GetBindable(Static.LastRankChangeSamplePlaybackTime); + } + protected override void LoadComplete() { - scoreProcessor.Rank.BindValueChanged(v => + base.LoadComplete(); + + updateRank(scoreProcessor.Rank.Value); + } + + protected override void Update() + { + base.Update(); + + var currentRank = scoreProcessor.Rank.Value; + + if (currentRank == displayedRank) + return; + + if (Time.Current - lastChangeTime >= time_between_changes || scoreProcessor.HasCompleted.Value || currentRank == ScoreRank.F) + updateRank(currentRank); + } + + private void updateRank(ScoreRank rank) + { + var texture = source.GetTexture($"ranking-{rank}-small"); + + rankDisplay.Texture = texture; + + if (texture != null && displayedRank != null) { - var texture = source.GetTexture($"ranking-{v.NewValue}-small"); - - rank.Texture = texture; - - if (texture != null) + var transientRank = new Sprite { - var transientRank = new Sprite - { - Texture = texture, - Blending = BlendingParameters.Additive, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - BypassAutoSizeAxes = Axes.Both, - }; - AddInternal(transientRank); - transientRank.FadeOutFromOne(500, Easing.Out) - .ScaleTo(new Vector2(1.625f), 500, Easing.Out) - .Expire(); - } - }, true); - FinishTransforms(true); + Texture = texture, + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BypassAutoSizeAxes = Axes.Both, + }; + + AddInternal(transientRank); + + transientRank.FadeOutFromOne(500, Easing.Out) + .ScaleTo(new Vector2(1.625f), 500, Easing.Out) + .Expire(); + } + + // Check sample time separately to ensure two copies of the rank display don't both play samples on a change. + bool enoughSampleTimeElapsed = !lastSamplePlayback.Value.HasValue || Time.Current - lastSamplePlayback.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; + + // Also don't play rank-down sfx on quit/retry/initial update. + if (displayedRank != null && rank > ScoreRank.F && PlaySamples.Value && enoughSampleTimeElapsed) + { + if (rank > displayedRank) + rankUpSample.Play(); + else + rankDownSample.Play(); + + lastSamplePlayback.Value = Time.Current; + } + + displayedRank = rank; + lastChangeTime = Time.Current; } } } diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 6faadfba9b..11b3b5c71d 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -145,12 +145,10 @@ namespace osu.Game.Skinning return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.WidthForNoteHeightScale: - Debug.Assert(maniaLookup.ColumnIndex != null); - return SkinUtils.As(new Bindable(existing.WidthForNoteHeightScale)); - - case LegacyManiaSkinConfigurationLookups.ColumnSpacing: - Debug.Assert(maniaLookup.ColumnIndex != null); - return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value])); + float width = existing.WidthForNoteHeightScale; + if (width <= 0) + width = existing.MinimumColumnWidth; + return SkinUtils.As(new Bindable(width)); case LegacyManiaSkinConfigurationLookups.HitPosition: return SkinUtils.As(new Bindable(existing.HitPosition)); @@ -170,17 +168,6 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.ExplosionImage: return SkinUtils.As(getManiaImage(existing, "LightingN")); - case LegacyManiaSkinConfigurationLookups.ExplosionScale: - Debug.Assert(maniaLookup.ColumnIndex != null); - - if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) - return SkinUtils.As(new Bindable(1)); - - if (existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] != 0) - return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - case LegacyManiaSkinConfigurationLookups.ColumnLineColour: return SkinUtils.As(getCustomColour(existing, "ColourColumnLine")); @@ -198,9 +185,15 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.ComboBreakColour: return SkinUtils.As(getCustomColour(existing, "ColourBreak")); + case LegacyManiaSkinConfigurationLookups.BarLineColour: + return SkinUtils.As(getCustomColour(existing, "ColourBarline")); + case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); + case LegacyManiaSkinConfigurationLookups.BarLineHeight: + return SkinUtils.As(new Bindable(existing.BarLineHeight)); + case LegacyManiaSkinConfigurationLookups.NoteBodyStyle: if (existing.NoteBodyStyle != null) @@ -230,17 +223,6 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.HoldNoteLightImage: return SkinUtils.As(getManiaImage(existing, "LightingL")); - case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: - Debug.Assert(maniaLookup.ColumnIndex != null); - - if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) - return SkinUtils.As(new Bindable(1)); - - if (existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] != 0) - return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - case LegacyManiaSkinConfigurationLookups.KeyImage: Debug.Assert(maniaLookup.ColumnIndex != null); return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}")); @@ -264,14 +246,6 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.HitTargetImage: return SkinUtils.As(getManiaImage(existing, "StageHint")); - case LegacyManiaSkinConfigurationLookups.LeftLineWidth: - Debug.Assert(maniaLookup.ColumnIndex != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value])); - - case LegacyManiaSkinConfigurationLookups.RightLineWidth: - Debug.Assert(maniaLookup.ColumnIndex != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value + 1])); - case LegacyManiaSkinConfigurationLookups.Hit0: case LegacyManiaSkinConfigurationLookups.Hit50: case LegacyManiaSkinConfigurationLookups.Hit100: @@ -285,6 +259,50 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.LightFramePerSecond: return SkinUtils.As(new Bindable(existing.LightFramePerSecond)); + + case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing: + Debug.Assert(maniaLookup.ColumnIndex != null); + if (maniaLookup.ColumnIndex == 0) + return SkinUtils.As(new Bindable()); + + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value - 1] / 2)); + + case LegacyManiaSkinConfigurationLookups.RightColumnSpacing: + Debug.Assert(maniaLookup.ColumnIndex != null); + if (maniaLookup.ColumnIndex == existing.ColumnSpacing.Length) + return SkinUtils.As(new Bindable()); + + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value] / 2)); + + case LegacyManiaSkinConfigurationLookups.LeftLineWidth: + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value])); + + case LegacyManiaSkinConfigurationLookups.RightLineWidth: + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value + 1])); + + case LegacyManiaSkinConfigurationLookups.ExplosionScale: + Debug.Assert(maniaLookup.ColumnIndex != null); + + if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) + return SkinUtils.As(new Bindable(1)); + + if (existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: + Debug.Assert(maniaLookup.ColumnIndex != null); + + if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) + return SkinUtils.As(new Bindable(1)); + + if (existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); } return null; @@ -367,16 +385,38 @@ namespace osu.Game.Skinning return new DefaultSkinComponentsContainer(container => { var combo = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + var leaderboard = container.OfType().FirstOrDefault(); + + Vector2 pos = new Vector2(); if (combo != null) { combo.Anchor = Anchor.BottomLeft; combo.Origin = Anchor.BottomLeft; combo.Scale = new Vector2(1.28f); + + pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X); + } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = pos; + } + + if (leaderboard != null) + { + leaderboard.Anchor = Anchor.CentreLeft; + leaderboard.Origin = Anchor.CentreLeft; + leaderboard.X = 10; } }) { - new LegacyDefaultComboCounter() + new LegacyDefaultComboCounter(), + new SpectatorList(), + new DrawableGameplayLeaderboard(), }; } @@ -556,12 +596,17 @@ namespace osu.Game.Skinning { var lookupNames = hitSample.LookupNames.SelectMany(getFallbackSampleNames); - if (!UseCustomSampleBanks && !string.IsNullOrEmpty(hitSample.Suffix)) + if (!string.IsNullOrEmpty(hitSample.Suffix)) { - // for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin. + // for compatibility with stable: + // - if the skin can use custom sample banks, it MUST use the custom sample bank suffix. it is not allowed to fall back to a non-custom sound. + // - if the skin cannot use custom sample banks, it MUST NOT use the custom sample bank suffix. // using .EndsWith() is intentional as it ensures parity in all edge cases - // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not). - lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); + // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply). + if (UseCustomSampleBanks) + lookupNames = lookupNames.Where(name => name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); + else + lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); } foreach (string l in lookupNames) diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index f41bd89b7a..0932485349 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.cs @@ -53,13 +53,8 @@ namespace osu.Game.Skinning } } - private string? getPathForFile(string filename) - { - if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string? path)) - return path; - - return null; - } + private string? getPathForFile(string filename) => + fileToStoragePathMapping.Value.GetValueOrDefault(filename.ToLowerInvariant()); private void invalidateCache() => fileToStoragePathMapping = new Lazy>(initialiseFileCache); diff --git a/osu.Game/Skinning/ResourceStoreBackedSkin.cs b/osu.Game/Skinning/ResourceStoreBackedSkin.cs index 206c400a88..450794c4a8 100644 --- a/osu.Game/Skinning/ResourceStoreBackedSkin.cs +++ b/osu.Game/Skinning/ResourceStoreBackedSkin.cs @@ -33,7 +33,7 @@ namespace osu.Game.Skinning public ISample? GetSample(ISampleInfo sampleInfo) { - foreach (string? lookup in sampleInfo.LookupNames) + foreach (string lookup in sampleInfo.LookupNames) { ISample? sample = samples.Get(lookup); if (sample != null) diff --git a/osu.Game/Skinning/RetroSkin.cs b/osu.Game/Skinning/RetroSkin.cs new file mode 100644 index 0000000000..20214dfb67 --- /dev/null +++ b/osu.Game/Skinning/RetroSkin.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Extensions; +using osu.Game.IO; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + /// + /// A skin that looks like osu!stable as it was around 2008. + /// + /// + /// "Around 2008" was chosen as the cutoff for this skin because that's when the look of core gameplay settled into its final design (until ). Skin elements from later versions of osu! were preferred as long as they only fixed bugs or applied minor tweaks to 2008 elements. + /// + public class RetroSkin : LegacySkin + { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.RETRO_SKIN, + Name = "osu! \"retro\" (2008)", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(RetroSkin).GetInvariantInstantiationInfo(), + }; + + public RetroSkin(IStorageResourceProvider resources) + : this(CreateInfo(), resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public RetroSkin(SkinInfo skin, IStorageResourceProvider resources) + : base( + skin, + resources, + new NamespacedResourceStore(resources.Resources, "Skins/Retro") + ) + { + Configuration.ConfigDictionary[@"SliderBallFlip"] = "0"; + Configuration.ConfigDictionary[@"SliderBallFrames"] = "10"; + Configuration.ConfigDictionary[@"AllowSliderBallTint"] = "0"; + Configuration.ConfigDictionary[@"CursorTrailRotate"] = "0"; + Configuration.ConfigDictionary[@"Version"] = "1"; + + Configuration.CustomComboColours = + [ + new Color4(255, 150, 0, 255), + new Color4(5, 240, 5, 255), + new Color4(5, 5, 240, 255), + new Color4(240, 5, 5, 255) + ]; + + Configuration.ConfigDictionary[@"HitCircleOverlap"] = "3"; + Configuration.ConfigDictionary[@"ScoreOverlap"] = "3"; + Configuration.ConfigDictionary[@"ComboOverlap"] = "3"; + } + + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + // Retro taiko hit explosions use osu textures + if (componentName.StartsWith("taiko-hit", StringComparison.Ordinal)) + componentName = componentName.Substring(6); + + // Retro taiko slider has no fail variant, but it needs to exist to avoid displaying nothing + if (componentName == "taiko-slider-fail") + componentName = "taiko-slider"; + + return base.GetTexture(componentName, wrapModeS, wrapModeT); + } + } +} diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index e93a10d50b..fe0ce5afbc 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -221,14 +221,16 @@ namespace osu.Game.Skinning jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); jsonContent = jsonContent.Replace(@"osu.Game.Skinning.LegacyComboCounter", @"osu.Game.Skinning.LegacyDefaultComboCounter"); jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.UnstableRateCounter", @"osu.Game.Skinning.Triangles.TrianglesUnstableRateCounter"); try { // First attempt to deserialise using the new SkinLayoutInfo format layout = JsonConvert.DeserializeObject(jsonContent); } - catch + catch (Exception ex) { + Logger.Log($"Deserialising skin layout to {nameof(SkinLayoutInfo)} failed. Falling back to {nameof(SerialisedDrawableInfo)}[].\nDetails: {ex}"); } // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 70d3195ecd..6290e3439a 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -13,6 +15,7 @@ using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Overlays.Notifications; using Realms; namespace osu.Game.Skinning @@ -43,6 +46,50 @@ namespace osu.Game.Skinning private const string unknown_creator_string = @"Unknown"; + /// + /// Update an existing skin with the contents of a path + /// + /// The progress notification + /// The to update the with + /// The to update + /// + public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) + { + return await Realm.WriteAsync?>(r => + { + var skinInfo = r.Find(original.ID)!; + skinInfo.Files.Clear(); + + string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray(); + + foreach (string file in filesInMountedDirectory) + { + using var stream = File.OpenRead(Path.Combine(task.Path, file)); + + modelManager.AddFile(skinInfo, stream, file, r); + } + + string skinIniPath = Path.Combine(task.Path, "skin.ini"); + + if (File.Exists(skinIniPath)) + { + using (var stream = File.OpenRead(skinIniPath)) + using (var lineReader = new LineBufferedReader(stream)) + { + var decodedSkinIni = new LegacySkinDecoder().Decode(lineReader); + + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Name)) + skinInfo.Name = decodedSkinIni.SkinInfo.Name; + + if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Creator)) + skinInfo.Creator = decodedSkinIni.SkinInfo.Creator; + } + } + + return skinInfo.ToLive(Realm); + }).ConfigureAwait(false); + } + protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) { var skinInfoFile = model.GetFile(skin_info_file); @@ -110,17 +157,17 @@ namespace osu.Game.Skinning // Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching. // This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place. if (skinIniSourcedName != item.Name) - updateSkinIniMetadata(item, realm); + UpdateSkinIniMetadata(item, realm); } - private void updateSkinIniMetadata(SkinInfo item, Realm realm) + public void UpdateSkinIniMetadata(SkinInfo item, Realm realm) { string nameLine = @$"Name: {item.Name}"; string authorLine = @$"Author: {item.Creator}"; List newLines = new List { - @"// The following content was automatically added by osu! during import, based on filename / folder metadata.", + @"// The following content was automatically added by osu! in order to use metadata that more closely matches user expectations.", @"[General]", nameLine, authorLine, @@ -130,9 +177,10 @@ namespace osu.Game.Skinning if (existingFile == null) { - // skins without a skin.ini are supposed to import using the "latest version" spec. + // skins without a skin.ini are supposed to import using the "latest version" spec, unless we're making a copy of the retro skin which specifies 1.0. // see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298 - newLines.Add(FormattableString.Invariant($"Version: {SkinConfiguration.LATEST_VERSION}")); + decimal version = item.InstantiationInfo == typeof(RetroSkin).GetInvariantInstantiationInfo() ? 1.0M : SkinConfiguration.LATEST_VERSION; + newLines.Add(FormattableString.Invariant($"Version: {version}")); // In the case a skin doesn't have a skin.ini yet, let's create one. writeNewSkinIni(); diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 9763d3b57e..4c9c16e721 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -20,6 +20,7 @@ namespace osu.Game.Skinning internal static readonly Guid ARGON_SKIN = new Guid("CFFA69DE-B3E3-4DEE-8563-3C4F425C05D0"); internal static readonly Guid ARGON_PRO_SKIN = new Guid("9FC9CF5D-0F16-4C71-8256-98868321AC43"); internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187"); + internal static readonly Guid RETRO_SKIN = new Guid("0555C76A-CC6B-4BB4-9548-DF76BA72EF25"); internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908"); [PrimaryKey] diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 9018c2e2c3..e92d0d3d49 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -64,6 +64,8 @@ namespace osu.Game.Skinning private Skin trianglesSkin { get; } + private Skin retroSkin { get; } + public override bool PauseImports { get => base.PauseImports; @@ -91,6 +93,7 @@ namespace osu.Game.Skinning var defaultSkins = new[] { + retroSkin = new RetroSkin(this), DefaultClassicSkin = new DefaultLegacySkin(this), trianglesSkin = new TrianglesSkin(this), argonSkin = new ArgonSkin(this), @@ -131,6 +134,10 @@ namespace osu.Game.Skinning { Realm.Run(r => { + // can be the case when the current skin is externally mounted for editing + if (CurrentSkinInfo.Disabled) + return; + // Required local for iOS. Will cause runtime crash if inlined. Guid currentSkinId = CurrentSkinInfo.Value.ID; @@ -345,6 +352,15 @@ namespace osu.Game.Skinning }); } + public void Rename(Live skin, string newName) + { + skin.PerformWrite(s => + { + s.Name = newName; + skinImporter.UpdateSkinIniMetadata(s, s.Realm!); + }); + } + public void SetSkinFromConfiguration(string guidString) { Live skinInfo = null; @@ -356,6 +372,9 @@ namespace osu.Game.Skinning { if (guid == SkinInfo.CLASSIC_SKIN) skinInfo = DefaultClassicSkin.SkinInfo; + + if (guid == SkinInfo.RETRO_SKIN) + skinInfo = retroSkin.SkinInfo; } CurrentSkinInfo.Value = skinInfo ?? trianglesSkin.SkinInfo; diff --git a/osu.Game/Skinning/SkinnableContainer.cs b/osu.Game/Skinning/SkinnableContainer.cs index aad95ca779..720699e708 100644 --- a/osu.Game/Skinning/SkinnableContainer.cs +++ b/osu.Game/Skinning/SkinnableContainer.cs @@ -21,6 +21,11 @@ namespace osu.Game.Skinning /// public partial class SkinnableContainer : SkinReloadableDrawable, ISerialisableDrawableContainer { + /// + /// Invoked when the skinnable components of this container finish loading. + /// + public event Action? OnComponentsLoaded; + private Container? content; /// @@ -67,6 +72,7 @@ namespace osu.Game.Skinning AddInternal(wrapper); components.AddRange(wrapper.Children.OfType()); ComponentsLoaded = true; + OnComponentsLoaded?.Invoke(this); }, (cancellationSource = new CancellationTokenSource()).Token); } @@ -106,5 +112,12 @@ namespace osu.Game.Skinning Reload(); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + OnComponentsLoaded = null; + } } } diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 47618f6296..25bc32eaf2 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -29,7 +29,7 @@ namespace osu.Game.Skinning [Resolved] private TextureStore textures { get; set; } = null!; - [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.SpriteName), nameof(SkinnableComponentStrings.SpriteNameDescription), SettingControlType = typeof(SpriteSelectorControl))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.SpriteName), SettingControlType = typeof(SpriteSelectorControl))] public Bindable SpriteName { get; } = new Bindable(string.Empty); [Resolved] @@ -122,6 +122,7 @@ namespace osu.Game.Skinning || skin.GetType() == typeof(ArgonProSkin) || skin.GetType() == typeof(ArgonSkin) || skin.GetType() == typeof(DefaultLegacySkin) + || skin.GetType() == typeof(RetroSkin) || skin.GetType() == typeof(LegacySkin); } } diff --git a/osu.Game/Skinning/Triangles/TrianglesUnstableRateCounter.cs b/osu.Game/Skinning/Triangles/TrianglesUnstableRateCounter.cs new file mode 100644 index 0000000000..7c6046914d --- /dev/null +++ b/osu.Game/Skinning/Triangles/TrianglesUnstableRateCounter.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Skinning.Triangles +{ + public partial class TrianglesUnstableRateCounter : UnstableRateCounter, ISerialisableDrawable + { + private const float alpha_when_invalid = 0.3f; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.BlueLighter; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + IsValid.BindValueChanged(e => + DrawableCount.FadeTo(e.NewValue ? 1 : alpha_when_invalid, 1000, Easing.OutQuint), true); + FinishTransforms(true); + } + + protected override IHasText CreateText() => new TextComponent + { + Alpha = alpha_when_invalid + }; + + private partial 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, fixedWidth: true) + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 8, fixedWidth: true), + Text = @"UR", + Padding = new MarginPadding { Bottom = 1.5f }, + } + } + }; + } + } + } +} diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index d562fd3256..3881a5e970 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; +using osu.Game.Graphics; using osu.Game.IO; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; @@ -67,10 +68,6 @@ namespace osu.Game.Skinning switch (lookup) { case GlobalSkinnableContainerLookup containerLookup: - // Only handle global level defaults for now. - if (containerLookup.Ruleset != null) - return null; - switch (containerLookup.Lookup) { case GlobalSkinnableContainers.SongSelect: @@ -82,6 +79,44 @@ namespace osu.Game.Skinning return songSelectComponents; case GlobalSkinnableContainers.MainHUDComponents: + // elements default to beneath the health bar + const float score_vertical_offset = 30; + const float horizontal_padding = 20; + + const float screen_edge_padding = 10; + + // Hard to find this at runtime, so taken from the most expanded state during replay. + const float song_progress_offset_height = 73; + + if (containerLookup.Ruleset != null) + { + return new DefaultSkinComponentsContainer(container => + { + var leaderboard = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + if (leaderboard != null) + leaderboard.Position = new Vector2(40, 60); + + if (spectatorList != null) + { + spectatorList.HeaderFont.Value = Typeface.Venera; + spectatorList.HeaderColour.Value = new OsuColour().BlueLighter; + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = new Vector2(screen_edge_padding, -(song_progress_offset_height + screen_edge_padding)); + } + }) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new DrawableGameplayLeaderboard(), + new SpectatorList(), + }, + }; + } + var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); @@ -96,12 +131,7 @@ namespace osu.Game.Skinning score.Anchor = Anchor.TopCentre; score.Origin = Anchor.TopCentre; - // elements default to beneath the health bar - const float vertical_offset = 30; - - const float horizontal_padding = 20; - - score.Position = new Vector2(0, vertical_offset); + score.Position = new Vector2(0, score_vertical_offset); if (ppCounter != null) { @@ -112,13 +142,13 @@ namespace osu.Game.Skinning if (accuracy != null) { - accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, vertical_offset + 5); + accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, score_vertical_offset + 5); accuracy.Origin = Anchor.TopRight; accuracy.Anchor = Anchor.TopCentre; if (combo != null) { - combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5); + combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, score_vertical_offset + 5); combo.Anchor = Anchor.TopCentre; } } @@ -144,14 +174,9 @@ namespace osu.Game.Skinning if (songProgress != null && keyCounter != null) { - const float padding = 10; - - // Hard to find this at runtime, so taken from the most expanded state during replay. - const float song_progress_offset_height = 73; - keyCounter.Anchor = Anchor.BottomRight; keyCounter.Origin = Anchor.BottomRight; - keyCounter.Position = new Vector2(-padding, -(song_progress_offset_height + padding)); + keyCounter.Position = new Vector2(-screen_edge_padding, -(song_progress_offset_height + screen_edge_padding)); } }) { @@ -165,7 +190,7 @@ namespace osu.Game.Skinning new DefaultKeyCounterDisplay(), new BarHitErrorMeter(), new BarHitErrorMeter(), - new TrianglesPerformancePointsCounter() + new TrianglesPerformancePointsCounter(), } }; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 095bd95314..5ef6b30a82 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -35,7 +35,7 @@ namespace osu.Game.Storyboards.Drawables protected override Container Content { get; } - protected override Vector2 DrawScale => new Vector2(Parent!.DrawHeight / 480); + protected override Vector2 DrawScale => new Vector2((Parent?.DrawHeight ?? 0) / 480); public override bool RemoveCompletedTransforms => false; diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index 42426c8c85..5b3e7c3919 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -17,42 +17,55 @@ namespace osu.Game.Storyboards private readonly List triggerGroups = new List(); public string Path { get; } - public bool IsDrawable => HasCommands; + public virtual bool IsDrawable => HasCommands; public Anchor Origin; public Vector2 InitialPosition; public readonly StoryboardCommandGroup Commands = new StoryboardCommandGroup(); - public double StartTime + public virtual double StartTime { get { - // To get the initial start time, we need to check whether the first alpha command to exist (across all loops) has a StartValue of zero. - // A StartValue of zero governs, above all else, the first valid display time of a sprite. + // Users that are crafting storyboards using raw osb scripting or external tools may create alpha events far before the actual display time + // of sprites. // - // You can imagine that the first command of each type decides that type's start value, so if the initial alpha is zero, - // anything before that point can be ignored (the sprite is not visible after all). - var alphaCommands = new List<(double startTime, bool isZeroStartValue)>(); + // To make sure lifetime optimisations work as efficiently as they can, let's locally find the first time a sprite becomes visible. + var alphaCommands = new List>(); - var command = Commands.Alpha.FirstOrDefault(); - if (command != null) alphaCommands.Add((command.StartTime, command.StartValue == 0)); + foreach (var command in Commands.Alpha) + { + alphaCommands.Add(command); + if (visibleAtStartOrEnd(command)) + break; + } foreach (var loop in loopingGroups) { - command = loop.Alpha.FirstOrDefault(); - if (command != null) alphaCommands.Add((command.StartTime, command.StartValue == 0)); + foreach (var command in loop.Alpha) + { + alphaCommands.Add(command); + if (visibleAtStartOrEnd(command)) + break; + } } if (alphaCommands.Count > 0) { - var firstAlpha = alphaCommands.MinBy(t => t.startTime); + // Special care is given to cases where there's one or more no-op transforms (ie transforming from alpha 0 to alpha 0). + // - If a 0->0 transform exists, we still need to check it to ensure the absolute first start value is non-visible. + // - After ascertaining this, we then check the first non-noop transform to get the true start lifetime. + var firstAlpha = alphaCommands.MinBy(c => c.StartTime); + var firstRealAlpha = alphaCommands.Where(visibleAtStartOrEnd).MinBy(c => c.StartTime); - if (firstAlpha.isZeroStartValue) - return firstAlpha.startTime; + if (firstAlpha!.StartValue == 0 && firstRealAlpha != null) + return firstRealAlpha.StartTime; } return EarliestTransformTime; + + bool visibleAtStartOrEnd(StoryboardCommand command) => command.StartValue > 0 || command.EndValue > 0; } } diff --git a/osu.Game/Storyboards/StoryboardVideo.cs b/osu.Game/Storyboards/StoryboardVideo.cs index 14189a1a6c..5a9eb533c6 100644 --- a/osu.Game/Storyboards/StoryboardVideo.cs +++ b/osu.Game/Storyboards/StoryboardVideo.cs @@ -14,9 +14,13 @@ namespace osu.Game.Storyboards { // This is just required to get a valid StartTime based on the incoming offset. // Actual fades are handled inside DrawableStoryboardVideo for now. - Commands.AddAlpha(Easing.None, offset, offset, 0, 0); + StartTime = offset; } + public override double StartTime { get; } + + public override bool IsDrawable => true; + public override Drawable CreateDrawable() => new DrawableStoryboardVideo(this); } } diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 1f491be7e3..85c436e9c8 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -86,9 +86,7 @@ namespace osu.Game.Tests.Beatmaps currentTestBeatmap = Decoder.GetDecoder(reader).Decode(reader); // populate ruleset for beatmap converters that require it to be present. - var ruleset = rulesetStore.GetRuleset(currentTestBeatmap.BeatmapInfo.Ruleset.OnlineID); - - Debug.Assert(ruleset != null); + var ruleset = rulesetStore.GetRuleset(currentTestBeatmap.BeatmapInfo.Ruleset.OnlineID) ?? new RulesetInfo { OnlineID = currentTestBeatmap.BeatmapInfo.Ruleset.OnlineID }; currentTestBeatmap.BeatmapInfo.Ruleset = ruleset; }); diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index 1f0c08714e..caf99a4cf6 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -68,6 +68,7 @@ namespace osu.Game.Tests.Beatmaps var b = Decoder.GetDecoder(reader).Decode(reader); b.BeatmapInfo.MD5Hash = test_beatmap_hash.Value.md5; + b.BeatmapInfo.OnlineMD5Hash = test_beatmap_hash.Value.md5; b.BeatmapInfo.Hash = test_beatmap_hash.Value.sha2; return b; diff --git a/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs b/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs new file mode 100644 index 0000000000..eaef2af7c8 --- /dev/null +++ b/osu.Game/Tests/Beatmaps/TestBeatmapStore.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.Threading; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Database; + +namespace osu.Game.Tests.Beatmaps +{ + internal partial class TestBeatmapStore : BeatmapStore + { + public readonly BindableList BeatmapSets = new BindableList(); + public override IBindableList GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets.GetBoundCopy(); + } +} diff --git a/osu.Game/Tests/Gameplay/TestGameplayState.cs b/osu.Game/Tests/Gameplay/TestGameplayState.cs index 8fad6d1e23..2f1439f129 100644 --- a/osu.Game/Tests/Gameplay/TestGameplayState.cs +++ b/osu.Game/Tests/Gameplay/TestGameplayState.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Bindables; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -18,7 +19,7 @@ namespace osu.Game.Tests.Gameplay /// /// Creates a correctly-initialised instance for use in testing. /// - public static GameplayState Create(Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null) + public static GameplayState Create(Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null, IBindable? playState = null) { var beatmap = new TestBeatmap(ruleset.RulesetInfo); var workingBeatmap = new TestWorkingBeatmap(beatmap); @@ -29,7 +30,7 @@ namespace osu.Game.Tests.Gameplay var healthProcessor = ruleset.CreateHealthProcessor(beatmap.HitObjects[0].StartTime); - return new GameplayState(playableBeatmap, ruleset, mods, score, scoreProcessor, healthProcessor); + return new GameplayState(playableBeatmap, ruleset, mods, score, scoreProcessor, healthProcessor, localUserPlayingState: playState); } } } diff --git a/osu.Game/Tests/Visual/EditorSavingTestScene.cs b/osu.Game/Tests/Visual/EditorSavingTestScene.cs index 78188d7cf7..8d27618c00 100644 --- a/osu.Game/Tests/Visual/EditorSavingTestScene.cs +++ b/osu.Game/Tests/Visual/EditorSavingTestScene.cs @@ -13,7 +13,7 @@ using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; -using osu.Game.Screens.Select; +using osu.Game.Screens.SelectV2; using osuTK.Input; namespace osu.Game.Tests.Visual @@ -74,15 +74,14 @@ namespace osu.Game.Tests.Visual AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu); - SongSelect songSelect = null; + SoloSongSelect songSelect = null; - PushAndConfirm(() => songSelect = new PlaySongSelect()); - AddUntilStep("wait for carousel load", () => songSelect.BeatmapSetsLoaded); + PushAndConfirm(() => songSelect = new SoloSongSelect()); + AddUntilStep("wait for carousel load", () => songSelect.CarouselItemsPresented && !songSelect.IsFiltering); AddStep("Present same beatmap", () => Game.PresentBeatmap(Game.BeatmapManager.QueryBeatmapSet(set => set.ID == beatmapSetGuid)!.Value, beatmap => beatmap.ID == beatmapGuid)); AddUntilStep("Wait for beatmap selected", () => Game.Beatmap.Value.BeatmapInfo.ID == beatmapGuid); - AddStep("Open options", () => InputManager.Key(Key.F3)); - AddStep("Enter editor", () => InputManager.Key(Key.Number5)); + AddStep("Open editor", () => songSelect.Edit(Game.Beatmap.Value.BeatmapInfo)); AddUntilStep("Wait for editor load", () => Editor != null); } diff --git a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs index 6908f7f1b4..21d0b8e7a8 100644 --- a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs +++ b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs @@ -658,11 +658,10 @@ namespace osu.Game.Tests.Visual.Gameplay private partial class TestModSelectOverlay : UserModSelectOverlay { - protected override bool ShowPresets => false; - public TestModSelectOverlay() : base(OverlayColourScheme.Aquamarine) { + ShowPresets = false; } } } diff --git a/osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.cs b/osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.cs new file mode 100644 index 0000000000..5f973d1e4e --- /dev/null +++ b/osu.Game/Tests/Visual/LegacyReplayPlaybackTestScene.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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO.Legacy; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual +{ + /// + /// The goal of this abstract test class is to exercise correct playback of replays sourced from previous osu! versions. + /// Use to exercise that property. + /// + [HeadlessTest] + [TestFixture] + public abstract partial class LegacyReplayPlaybackTestScene : RateAdjustedBeatmapTestScene + { + private ReplayPlayer currentPlayer = null!; + private readonly List results = new List(); + + /// + /// This is provided as a convenience for testing behaviour against osu!stable. + /// Setting this field to a non-null path will cause beatmap files and replays used in all test cases + /// to be exported to disk so that they can be cross-checked against stable. + /// + protected abstract string? ExportLocation { get; } + + /// + /// Encodes the supplied , decodes the result of encoding, runs the result of decoding against the supplied , + /// and checks that the judgement results recorded still match . + /// If is set, exports both the beatmap and the replay to said location. + /// + protected void RunTest(string beatmapName, IBeatmap beatmap, string replayName, Score originalScore, IEnumerable expectedResults) + { + IBeatmap playableBeatmap = null!; + MemoryStream beatmapStream = new MemoryStream(); + MemoryStream scoreStream = new MemoryStream(); + Score decodedScore = null!; + + AddStep(@"set up beatmap", () => + { + beatmap.Metadata.Title = beatmapName; + Beatmap.Value = CreateWorkingBeatmap(beatmap); + Ruleset.Value = CreateRuleset()!.RulesetInfo; + playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + + var beatmapEncoder = new LegacyBeatmapEncoder(beatmap, null); + + using (var writer = new StreamWriter(beatmapStream, Encoding.UTF8, leaveOpen: true)) + beatmapEncoder.Encode(writer); + + beatmapStream.Seek(0, SeekOrigin.Begin); + playableBeatmap.BeatmapInfo.MD5Hash = beatmapStream.ComputeMD5Hash(); + }); + + AddStep(@"encode score", () => + { + originalScore.ScoreInfo.BeatmapInfo = playableBeatmap.BeatmapInfo; + var encoder = new LegacyScoreEncoder(originalScore, playableBeatmap); + encoder.Encode(scoreStream, leaveOpen: true); + + // `LegacyScoreEncoder` hardcodes a replay version that belongs to lazer. + // here we want to simulate a stable replay, which should have the classic mod attached etc. + // to that end, we do a post-encode step to specify a stable-like replay version. + scoreStream.Position = 1; + + using (var sw = new SerializationWriter(scoreStream, leaveOpen: true)) + { + const int version = 20250414; + sw.Write(version); + } + + scoreStream.Position = 0; + }); + + if (ExportLocation != null) + { + AddStep("export beatmap", () => + { + using var stream = File.Open(Path.Combine(ExportLocation, $"{beatmapName}.osu"), FileMode.Create); + beatmapStream.CopyTo(stream); + beatmapStream.Position = 0; + }); + + AddStep("export score", () => + { + using var stream = File.Open(Path.Combine(ExportLocation, $@"{replayName}.osr"), FileMode.Create); + scoreStream.CopyTo(stream); + scoreStream.Position = 0; + }); + } + + AddStep(@"decode score", () => + { + using (scoreStream) + { + scoreStream.Position = 0; + decodedScore = new TestScoreDecoder(Beatmap.Value, Ruleset.Value).Parse(scoreStream); + } + }); + + AddAssert(@"classic mod present", () => decodedScore.ScoreInfo.Mods.Any(mod => mod is ModClassic)); + AddStep(@"push player", () => pushNewPlayer(decodedScore)); + + AddUntilStep(@"Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddAssert(@"classic mod present", () => currentPlayer.GameplayState.Mods.Any(mod => mod is ModClassic)); + AddUntilStep(@"Wait for completion", () => currentPlayer.GameplayState.HasCompleted); + AddAssert(@"judgement results after encode are correct", () => results.Select(r => r.Type), () => Is.EquivalentTo(expectedResults)); + } + + private void pushNewPlayer(Score score) + { + var player = new ReplayPlayer(score); + SelectedMods.Value = score.ScoreInfo.Mods; + player.OnLoadComplete += _ => + { + player.GameplayState.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == player) + results.Add(result); + }; + }; + LoadScreen(currentPlayer = player); + results.Clear(); + } + + private class TestScoreDecoder : LegacyScoreDecoder + { + private readonly WorkingBeatmap beatmap; + private readonly Ruleset ruleset; + + public TestScoreDecoder(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + this.beatmap = beatmap; + this.ruleset = ruleset.CreateInstance(); + } + + protected override Ruleset GetRuleset(int rulesetId) => ruleset; + protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmap; + } + } +} diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 4a862750bc..dca1b0e468 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -16,11 +16,14 @@ namespace osu.Game.Tests.Visual.Metadata public override IBindable IsConnected => isConnected; private readonly BindableBool isConnected = new BindableBool(true); - public override IBindable IsWatchingUserPresence => isWatchingUserPresence; - private readonly BindableBool isWatchingUserPresence = new BindableBool(); + public override UserPresence LocalUserPresence => localUserPresence; + private UserPresence localUserPresence; - public override IBindableDictionary UserStates => userStates; - private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary UserPresences => userPresences; + private readonly BindableDictionary userPresences = new BindableDictionary(); + + public override IBindableDictionary FriendPresences => friendPresences; + private readonly BindableDictionary friendPresences = new BindableDictionary(); public override Bindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); @@ -28,25 +31,29 @@ namespace osu.Game.Tests.Visual.Metadata [Resolved] private IAPIProvider api { get; set; } = null!; - public override Task BeginWatchingUserPresence() + public event Action? OnBeginWatchingUserPresence; + public event Action? OnEndWatchingUserPresence; + + protected override Task BeginWatchingUserPresenceInternal() { - isWatchingUserPresence.Value = true; + OnBeginWatchingUserPresence?.Invoke(); return Task.CompletedTask; } - public override Task EndWatchingUserPresence() + protected override Task EndWatchingUserPresenceInternal() { - isWatchingUserPresence.Value = false; + OnEndWatchingUserPresence?.Invoke(); return Task.CompletedTask; } public override Task UpdateActivity(UserActivity? activity) { - if (isWatchingUserPresence.Value) + localUserPresence = localUserPresence with { Activity = activity }; + + if (IsWatchingUserPresence) { - userStates.TryGetValue(api.LocalUser.Value.Id, out var localUserPresence); - localUserPresence = localUserPresence with { Activity = activity }; - userStates[api.LocalUser.Value.Id] = localUserPresence; + if (userPresences.ContainsKey(api.LocalUser.Value.Id)) + userPresences[api.LocalUser.Value.Id] = localUserPresence; } return Task.CompletedTask; @@ -54,11 +61,12 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UpdateStatus(UserStatus? status) { - if (isWatchingUserPresence.Value) + localUserPresence = localUserPresence with { Status = status }; + + if (IsWatchingUserPresence) { - userStates.TryGetValue(api.LocalUser.Value.Id, out var localUserPresence); - localUserPresence = localUserPresence with { Status = status }; - userStates[api.LocalUser.Value.Id] = localUserPresence; + if (userPresences.ContainsKey(api.LocalUser.Value.Id)) + userPresences[api.LocalUser.Value.Id] = localUserPresence; } return Task.CompletedTask; @@ -66,17 +74,37 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UserPresenceUpdated(int userId, UserPresence? presence) { - if (isWatchingUserPresence.Value) + if (IsWatchingUserPresence) { - if (presence.HasValue) - userStates[userId] = presence.Value; + if (presence?.Status != null) + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = presence.Value; + else + userPresences[userId] = presence.Value; + } else - userStates.Remove(userId); + { + if (userId == api.LocalUser.Value.OnlineID) + localUserPresence = default; + else + userPresences.Remove(userId); + } } return Task.CompletedTask; } + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) + { + if (presence.HasValue) + friendPresences[userId] = presence.Value; + else + friendPresences.Remove(userId); + + return Task.CompletedTask; + } + public override Task GetChangesSince(int queueId) => Task.FromResult(new BeatmapUpdates(Array.Empty(), queueId)); diff --git a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs index efd0b80ebf..262816ae89 100644 --- a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; @@ -17,11 +16,6 @@ namespace osu.Game.Tests.Visual.Multiplayer /// TestMultiplayerClient MultiplayerClient { get; } - /// - /// The cached . - /// - new TestMultiplayerRoomManager RoomManager { get; } - /// /// The cached . /// diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 42cf317829..316e90d7d3 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Visual.OnlinePlay; @@ -17,26 +18,18 @@ namespace osu.Game.Tests.Visual.Multiplayer public const int PLAYER_2_ID = 56; public TestMultiplayerClient MultiplayerClient => OnlinePlayDependencies.MultiplayerClient; - public new TestMultiplayerRoomManager RoomManager => OnlinePlayDependencies.RoomManager; public TestSpectatorClient SpectatorClient => OnlinePlayDependencies.SpectatorClient; protected new MultiplayerTestSceneDependencies OnlinePlayDependencies => (MultiplayerTestSceneDependencies)base.OnlinePlayDependencies; public bool RoomJoined => MultiplayerClient.RoomJoined; - private readonly bool joinRoom; - - protected MultiplayerTestScene(bool joinRoom = true) - { - this.joinRoom = joinRoom; - } - - protected virtual Room CreateRoom() + protected Room CreateDefaultRoom(MatchType type = MatchType.HeadToHead) { return new Room { Name = "test name", - Type = MatchType.HeadToHead, + Type = type, Playlist = [ new PlaylistItem(new TestBeatmap(Ruleset.Value).BeatmapInfo) @@ -47,21 +40,12 @@ namespace osu.Game.Tests.Visual.Multiplayer }; } - public override void SetUpSteps() - { - base.SetUpSteps(); + /// + /// Creates and joins a basic multiplayer room. + /// + protected void JoinRoom(Room room) => MultiplayerClient.CreateRoom(room).FireAndForget(); - if (joinRoom) - { - AddStep("join room", () => - { - SelectedRoom.Value = CreateRoom(); - RoomManager.CreateRoom(SelectedRoom.Value); - }); - - AddUntilStep("wait for room join", () => RoomJoined); - } - } + protected void WaitForJoined() => AddUntilStep("wait for room join", () => RoomJoined); protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new MultiplayerTestSceneDependencies(); } diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs index 88202d4327..24c33f2f49 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs @@ -3,7 +3,6 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; -using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; @@ -16,19 +15,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { public TestMultiplayerClient MultiplayerClient { get; } public TestSpectatorClient SpectatorClient { get; } - public new TestMultiplayerRoomManager RoomManager => (TestMultiplayerRoomManager)base.RoomManager; public MultiplayerTestSceneDependencies() { - MultiplayerClient = new TestMultiplayerClient(RoomManager); + MultiplayerClient = new TestMultiplayerClient(RequestsHandler); SpectatorClient = CreateSpectatorClient(); CacheAs(MultiplayerClient); CacheAs(SpectatorClient); } - protected override IRoomManager CreateRoomManager() => new TestMultiplayerRoomManager(RequestsHandler); - 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 4d812abf11..38070d953e 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -10,13 +10,19 @@ using MessagePack; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Matchmaking.Events; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; +using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Multiplayer { @@ -65,15 +71,18 @@ namespace osu.Game.Tests.Visual.Multiplayer [Resolved] private IAPIProvider api { get; set; } = null!; - private readonly TestMultiplayerRoomManager roomManager; - private MultiplayerPlaylistItem? currentItem => ServerRoom?.Playlist[currentIndex]; private int currentIndex; private long lastPlaylistItemId; + private int lastCountdownId; - public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) + private readonly Dictionary matchmakingUserPicks = new Dictionary(); + + private readonly TestRoomRequestsHandler apiRequestHandler; + + public TestMultiplayerClient(TestRoomRequestsHandler? apiRequestHandler = null) { - this.roomManager = roomManager; + this.apiRequestHandler = apiRequestHandler ?? new TestRoomRequestsHandler(); } public void Connect() => isConnected.Value = true; @@ -206,7 +215,7 @@ namespace osu.Game.Tests.Visual.Multiplayer ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged(clone(userId), clone(user.BeatmapAvailability)); } - protected override async Task JoinRoom(long roomId, string? password = null) + protected override async Task JoinRoomInternal(long roomId, string? password = null) { if (RoomJoined || ServerAPIRoom != null) throw new InvalidOperationException("Already joined a room"); @@ -214,7 +223,7 @@ namespace osu.Game.Tests.Visual.Multiplayer roomId = clone(roomId); password = clone(password); - ServerAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID == roomId); + ServerAPIRoom = ServerSideRooms.Single(r => r.RoomID == roomId); if (password != ServerAPIRoom.Password) throw new InvalidOperationException("Invalid password."); @@ -236,11 +245,12 @@ namespace osu.Game.Tests.Visual.Multiplayer QueueMode = ServerAPIRoom.QueueMode, AutoStartDuration = ServerAPIRoom.AutoStartDuration }, - Playlist = ServerAPIRoom.Playlist.Select(CreateMultiplayerPlaylistItem).ToList(), + Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), Users = { localUser }, Host = localUser }; + await changeMatchType(ServerRoom.Settings.MatchType).ConfigureAwait(false); await updatePlaylistOrder(ServerRoom).ConfigureAwait(false); await updateCurrentItem(ServerRoom, false).ConfigureAwait(false); @@ -253,10 +263,6 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override void OnRoomJoined() { Debug.Assert(ServerRoom != 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(ServerRoom.Settings.MatchType).WaitSafely(); - RoomJoined = true; } @@ -296,14 +302,24 @@ namespace osu.Game.Tests.Visual.Multiplayer return ((IMultiplayerClient)this).UserKicked(clone(user)); } + /// + /// Simulates a change to the server-side room's settings without any other change. + /// + public async Task ChangeServerRoomSettings(MultiplayerRoomSettings settings) + { + Debug.Assert(ServerRoom != null); + + ServerRoom.Settings = settings; + + await ((IMultiplayerClient)this).SettingsChanged(clone(settings)).ConfigureAwait(false); + } + public override async Task ChangeSettings(MultiplayerRoomSettings settings) { - settings = clone(settings); - Debug.Assert(ServerRoom != null); - Debug.Assert(currentItem != null); // Server is authoritative for the time being. + settings = clone(settings); settings.PlaylistItemId = ServerRoom.Settings.PlaylistItemId; ServerRoom.Settings = settings; @@ -335,6 +351,23 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + public override Task ChangeUserStyle(int? beatmapId, int? rulesetId) + { + ChangeUserStyle(api.LocalUser.Value.Id, beatmapId, rulesetId); + return Task.CompletedTask; + } + + public void ChangeUserStyle(int userId, int? beatmapId, int? rulesetId) + { + Debug.Assert(ServerRoom != null); + + var user = ServerRoom.Users.Single(u => u.UserID == userId); + user.BeatmapId = beatmapId; + user.RulesetId = rulesetId; + + ((IMultiplayerClient)this).UserStyleChanged(userId, beatmapId, rulesetId); + } + public void ChangeUserMods(int userId, IEnumerable newMods) => ChangeUserMods(userId, newMods.Select(m => new APIMod(m))); @@ -354,7 +387,10 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } - public override async Task SendMatchRequest(MatchUserRequest request) + public override Task SendMatchRequest(MatchUserRequest request) + => SendUserMatchRequest(api.LocalUser.Value.OnlineID, request); + + public async Task SendUserMatchRequest(int userId, MatchUserRequest request) { request = clone(request); @@ -364,7 +400,6 @@ namespace osu.Game.Tests.Visual.Multiplayer switch (request) { case ChangeTeamRequest changeTeam: - TeamVersusRoomState roomState = (TeamVersusRoomState)ServerRoom.MatchState!; TeamVersusUserState userState = (TeamVersusUserState)LocalUser.MatchState!; @@ -373,14 +408,57 @@ namespace osu.Game.Tests.Visual.Multiplayer if (targetTeam != null) { userState.TeamID = targetTeam.ID; - - await ((IMultiplayerClient)this).MatchUserStateChanged(clone(LocalUser.UserID), clone(userState)).ConfigureAwait(false); + await ((IMultiplayerClient)this).MatchUserStateChanged(userId, clone(userState)).ConfigureAwait(false); } break; + + case StartMatchCountdownRequest startCountdown: + await StartCountdown(new MatchStartCountdown { TimeRemaining = startCountdown.Duration }).ConfigureAwait(false); + break; + + case StopCountdownRequest stopCountdown: + await StopCountdown(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID)).ConfigureAwait(false); + break; + + case MatchmakingAvatarActionRequest avatarAction: + await ((IMultiplayerClient)this).MatchEvent(new MatchmakingAvatarActionEvent + { + UserId = userId, + Action = avatarAction.Action + }).ConfigureAwait(false); + break; } } + public async Task StartCountdown(MultiplayerCountdown countdown) + { + countdown.ID = ++lastCountdownId; + countdown = clone(countdown); + + Debug.Assert(ServerRoom != null); + Debug.Assert(LocalUser != null); + + if (countdown.IsExclusive) + { + MultiplayerCountdown? existingCountdown = ServerRoom.ActiveCountdowns.FirstOrDefault(c => c.GetType() == countdown.GetType()); + if (existingCountdown != null) + await StopCountdown(existingCountdown).ConfigureAwait(false); + } + + ServerRoom.ActiveCountdowns.Add(countdown); + await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStartedEvent(ServerRoom.ActiveCountdowns[^1]))).ConfigureAwait(false); + } + + public async Task StopCountdown(MultiplayerCountdown countdown) + { + Debug.Assert(ServerRoom != null); + Debug.Assert(LocalUser != null); + + ServerRoom.ActiveCountdowns.Remove(ServerRoom.ActiveCountdowns.First(c => c.ID == countdown.ID)); + await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStoppedEvent(countdown.ID))).ConfigureAwait(false); + } + public override Task StartMatch() { Debug.Assert(ServerRoom != null); @@ -464,7 +542,7 @@ namespace osu.Game.Tests.Visual.Multiplayer if (item == null) throw new InvalidOperationException("Item does not exist in the room."); - if (item == currentItem) + if (item.Equals(currentItem)) throw new InvalidOperationException("The room's current item cannot be removed."); if (item.OwnerID != userId) @@ -483,6 +561,29 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(playlistItemId)); + public override Task VoteToSkipIntro() + { + return UserVoteToSkipIntro(api.LocalUser.Value.OnlineID); + } + + public async Task UserVoteToSkipIntro(int userId) + { + await ((IMultiplayerClient)this).UserVotedToSkipIntro(userId).ConfigureAwait(false); + } + + protected override Task CreateRoomInternal(MultiplayerRoom room) + { + Room apiRoom = new Room(room) + { + Type = room.Settings.MatchType == MatchType.Playlists + ? MatchType.HeadToHead + : room.Settings.MatchType + }; + + AddServerSideRoom(apiRoom, api.LocalUser.Value); + return JoinRoomInternal(apiRoom.RoomID!.Value, room.Settings.Password); + } + private async Task changeMatchType(MatchType type) { Debug.Assert(ServerRoom != null); @@ -511,6 +612,18 @@ namespace osu.Game.Tests.Visual.Multiplayer await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); } + break; + + case MatchType.Matchmaking: + ServerRoom.MatchState = new MatchmakingRoomState(); + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + + foreach (var user in ServerRoom.Users) + { + user.MatchState = null; + await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); + } + break; } } @@ -655,25 +768,114 @@ namespace osu.Game.Tests.Visual.Multiplayer return MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS); } - public static MultiplayerPlaylistItem CreateMultiplayerPlaylistItem(PlaylistItem item) => new MultiplayerPlaylistItem - { - ID = item.ID, - OwnerID = item.OwnerID, - BeatmapID = item.Beatmap.OnlineID, - BeatmapChecksum = item.Beatmap.MD5Hash, - RulesetID = item.RulesetID, - RequiredMods = item.RequiredMods.ToArray(), - AllowedMods = item.AllowedMods.ToArray(), - Expired = item.Expired, - PlaylistOrder = item.PlaylistOrder ?? 0, - PlayedAt = item.PlayedAt, - StarRating = item.Beatmap.StarRating, - }; - public override Task DisconnectInternal() { isConnected.Value = false; return Task.CompletedTask; } + + public async Task ChangeMatchRoomState(MatchRoomState state) + { + Debug.Assert(ServerRoom != null); + + ServerRoom.MatchState = state; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + } + + public override Task GetMatchmakingPools() + { + return Task.FromResult( + [ + new MatchmakingPool { Id = 0, RulesetId = 0 }, + new MatchmakingPool { Id = 1, RulesetId = 1 }, + new MatchmakingPool { Id = 2, RulesetId = 2 }, + new MatchmakingPool { Id = 3, RulesetId = 3, Variant = 4 }, + new MatchmakingPool { Id = 4, RulesetId = 3, Variant = 7 }, + ]); + } + + public override Task MatchmakingJoinLobby() + { + return Task.CompletedTask; + } + + public override Task MatchmakingLeaveLobby() + { + return Task.CompletedTask; + } + + public override async Task MatchmakingJoinQueue(int poolId) + { + await ((IMatchmakingClient)this).MatchmakingQueueJoined().ConfigureAwait(false); + await ((IMatchmakingClient)this).MatchmakingQueueStatusChanged(new MatchmakingQueueStatus.Searching()).ConfigureAwait(false); + } + + public override async Task MatchmakingLeaveQueue() + { + await ((IMatchmakingClient)this).MatchmakingQueueLeft().ConfigureAwait(false); + } + + public override Task MatchmakingAcceptInvitation() + { + return Task.CompletedTask; + } + + public override Task MatchmakingDeclineInvitation() + { + return Task.CompletedTask; + } + + public override Task MatchmakingToggleSelection(long playlistItemId) + => MatchmakingToggleUserSelection(api.LocalUser.Value.OnlineID, playlistItemId); + + public override Task MatchmakingSkipToNextStage() + => Task.CompletedTask; + + public async Task MatchmakingToggleUserSelection(int userId, long playlistItemId) + { + if (matchmakingUserPicks.TryGetValue(userId, out long existingId)) + { + if (existingId == playlistItemId) + return; + + await ((IMatchmakingClient)this).MatchmakingItemDeselected(clone(userId), clone(existingId)).ConfigureAwait(false); + } + + matchmakingUserPicks[userId] = playlistItemId; + + await ((IMatchmakingClient)this).MatchmakingItemSelected(clone(userId), clone(playlistItemId)).ConfigureAwait(false); + } + + public async Task MatchmakingChangeStage(MatchmakingStage stage, Action? prepare = null) + { + MatchmakingRoomState state = clone((MatchmakingRoomState)ServerRoom!.MatchState!); + + state.Stage = stage; + + if (stage == MatchmakingStage.RoundWarmupTime) + state.CurrentRound++; + + prepare?.Invoke(state); + + await ChangeMatchRoomState(state).ConfigureAwait(false); + await StartCountdown(new MatchmakingStageCountdown + { + Stage = stage, + TimeRemaining = TimeSpan.FromSeconds(stage == MatchmakingStage.UserBeatmapSelect ? 30 : 10) + }).ConfigureAwait(false); + } + + #region API Room Handling + + public IReadOnlyList ServerSideRooms + => apiRequestHandler.ServerSideRooms; + + public void AddServerSideRoom(Room room, APIUser host) + => apiRequestHandler.AddServerSideRoom(room, host); + + public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager beatmapManager) + => apiRequestHandler.HandleRequest(request, localUser, beatmapManager); + + #endregion } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs deleted file mode 100644 index b998a638e5..0000000000 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Game.Online.API.Requests.Responses; -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. - /// Should generally not be used by itself outside of a . - /// - public partial class TestMultiplayerRoomManager : MultiplayerRoomManager - { - private readonly TestRoomRequestsHandler requestsHandler; - - public TestMultiplayerRoomManager(TestRoomRequestsHandler requestsHandler) - { - this.requestsHandler = requestsHandler; - } - - public IReadOnlyList ServerSideRooms => requestsHandler.ServerSideRooms; - - public override void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) - => base.CreateRoom(room, r => onSuccess?.Invoke(r), onError); - - public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - => base.JoinRoom(room, password, r => onSuccess?.Invoke(r), onError); - - /// - /// Adds a room to a local "server-side" list that's returned when a is fired. - /// - /// The room. - /// The host. - public void AddServerSideRoom(Room room, APIUser host) => requestsHandler.AddServerSideRoom(room, host); - } -} diff --git a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs index 8ddc5325db..0468c18076 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; using osu.Game.Database; -using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay; namespace osu.Game.Tests.Visual.OnlinePlay @@ -13,26 +11,11 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public interface IOnlinePlayTestSceneDependencies { - /// - /// The cached . - /// - Bindable SelectedRoom { get; } - - /// - /// The cached - /// - IRoomManager RoomManager { get; } - /// /// The cached . /// OngoingOperationTracker OngoingOperationTracker { get; } - /// - /// The cached . - /// - OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker { get; } - /// /// The cached . /// diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index 3f6c175fbd..75932bbfef 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -3,14 +3,15 @@ using System; 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.Database; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; +using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay; namespace osu.Game.Tests.Visual.OnlinePlay @@ -20,10 +21,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public abstract partial class OnlinePlayTestScene : ScreenTestScene, IOnlinePlayTestSceneDependencies { - public Bindable SelectedRoom => OnlinePlayDependencies.SelectedRoom; - public IRoomManager RoomManager => OnlinePlayDependencies.RoomManager; public OngoingOperationTracker OngoingOperationTracker => OnlinePlayDependencies.OngoingOperationTracker; - public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker => OnlinePlayDependencies.AvailabilityTracker; public TestUserLookupCache UserLookupCache => OnlinePlayDependencies.UserLookupCache; public BeatmapLookupCache BeatmapLookupCache => OnlinePlayDependencies.BeatmapLookupCache; @@ -37,6 +35,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay private readonly Container content; private readonly Container drawableDependenciesContainer; private DelegatedDependencyContainer dependencies = null!; + private int currentRoomId; protected OnlinePlayTestScene() { @@ -93,6 +92,35 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// protected virtual OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new OnlinePlayTestSceneDependencies(); + protected Room[] GenerateRooms(int count, RulesetInfo? ruleset = null, bool withPassword = false, bool withPinnedRooms = false) + { + Room[] rooms = new Room[count]; + + // Can't reference Osu ruleset project here. + if (ruleset == null) + { + using var assemblyRulesetStore = new AssemblyRulesetStore(); + ruleset = assemblyRulesetStore.GetRuleset(0)!; + } + + for (int i = 0; i < count; i++) + { + rooms[i] = new Room + { + RoomID = currentRoomId++, + Name = $@"Room {currentRoomId}", + Host = new APIUser { Username = @"Host" }, + Duration = TimeSpan.FromSeconds(10), + Password = withPassword ? @"password" : null, + PlaylistItemStats = new Room.RoomPlaylistItemStats { RulesetIDs = [ruleset.OnlineID] }, + Playlist = [new PlaylistItem(new BeatmapInfo { Metadata = new BeatmapMetadata() }) { RulesetID = ruleset.OnlineID }], + Pinned = withPinnedRooms && i % 2 == 0, + }; + } + + return rooms; + } + /// /// A providing a mutable lookup source for online play dependencies. /// diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs index e2670c9ad8..9d66a44008 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs @@ -4,10 +4,8 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Database; -using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay; @@ -18,10 +16,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public class OnlinePlayTestSceneDependencies : IReadOnlyDependencyContainer, IOnlinePlayTestSceneDependencies { - public Bindable SelectedRoom { get; } - public IRoomManager RoomManager { get; } public OngoingOperationTracker OngoingOperationTracker { get; } - public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker { get; } public TestRoomRequestsHandler RequestsHandler { get; } public TestUserLookupCache UserLookupCache { get; } public BeatmapLookupCache BeatmapLookupCache { get; } @@ -36,21 +31,15 @@ namespace osu.Game.Tests.Visual.OnlinePlay public OnlinePlayTestSceneDependencies() { - SelectedRoom = new Bindable(); RequestsHandler = new TestRoomRequestsHandler(); OngoingOperationTracker = new OngoingOperationTracker(); - AvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); - RoomManager = CreateRoomManager(); UserLookupCache = new TestUserLookupCache(); BeatmapLookupCache = new BeatmapLookupCache(); dependencies = new DependencyContainer(); CacheAs(RequestsHandler); - CacheAs(SelectedRoom); - CacheAs(RoomManager); CacheAs(OngoingOperationTracker); - CacheAs(AvailabilityTracker); CacheAs(new OverlayColourProvider(OverlayColourScheme.Plum)); CacheAs(UserLookupCache); CacheAs(BeatmapLookupCache); @@ -80,7 +69,5 @@ namespace osu.Game.Tests.Visual.OnlinePlay if (instance is Drawable drawable) drawableComponents.Add(drawable); } - - protected virtual IRoomManager CreateRoomManager() => new TestRoomManager(); } } diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs deleted file mode 100644 index b1e3eafacc..0000000000 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.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; -using osu.Game.Beatmaps; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Rooms; -using osu.Game.Rulesets; -using osu.Game.Screens.OnlinePlay.Components; - -namespace osu.Game.Tests.Visual.OnlinePlay -{ - /// - /// A very simple for use in online play test scenes. - /// - public partial class TestRoomManager : RoomManager - { - public Action? JoinRoomRequested; - - private int currentRoomId; - - public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) - { - JoinRoomRequested?.Invoke(room, password); - base.JoinRoom(room, password, onSuccess, onError); - } - - public void AddRooms(int count, RulesetInfo? ruleset = null, bool withPassword = false, bool withSpotlightRooms = false) - { - for (int i = 0; i < count; i++) - { - AddRoom(new Room - { - Name = $@"Room {currentRoomId}", - Host = new APIUser { Username = @"Host" }, - Duration = TimeSpan.FromSeconds(10), - Category = withSpotlightRooms && i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal, - Password = withPassword ? @"password" : null, - PlaylistItemStats = ruleset == null - ? null - : new Room.RoomPlaylistItemStats { RulesetIDs = [ruleset.OnlineID] }, - Playlist = ruleset == null - ? Array.Empty() - : [new PlaylistItem(new BeatmapInfo { Metadata = new BeatmapMetadata() }) { RulesetID = ruleset.OnlineID }] - }); - } - } - - public void AddRoom(Room room) - { - room.RoomID = -currentRoomId; - CreateRoom(room); - currentRoomId++; - } - } -} diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index c9149bda22..08f61f3ddc 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; @@ -15,7 +16,6 @@ using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Tests.Beatmaps; using osu.Game.Utils; @@ -28,7 +28,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay public class TestRoomRequestsHandler { public IReadOnlyList ServerSideRooms => serverSideRooms; - private readonly List serverSideRooms = new List(); private int currentRoomId = 1; @@ -36,8 +35,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay private int currentScoreId = 1; /// - /// Handles an API request, while also updating the local state to match - /// how the server would eventually respond and update an . + /// Handles an API request, while also updating the local state to match how the server would eventually respond. /// /// The API request to handle. /// The local user to store in responses where required. @@ -128,6 +126,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay MaxCombo = 100, TotalScore = 200000, User = new APIUser { Username = "worst user" }, + Mods = [new APIMod { Acronym = @"TD" }], Statistics = new Dictionary() }, }, @@ -221,7 +220,15 @@ namespace osu.Game.Tests.Visual.OnlinePlay : new APIUser { Id = id, - Username = $"User {id}" + Username = $"User {id}", + Team = RNG.NextBool() + ? new APITeam + { + Name = "Collective Wangs", + ShortName = "WANG", + FlagUrl = "https://assets.ppy.sh/teams/flag/1/wanglogo.jpg", + } + : null, }) .Where(u => u != null).ToList(), }); @@ -260,7 +267,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// The room host. public void AddServerSideRoom(Room room, APIUser host) { - room.RoomID ??= currentRoomId++; + room.RoomID = currentRoomId++; room.Host = host; for (int i = 0; i < room.Playlist.Count; i++) diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index b86273b4a3..fb229194d9 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -82,10 +82,11 @@ namespace osu.Game.Tests.Visual [TearDownSteps] public virtual void TearDownSteps() { - if (DebugUtils.IsNUnitRunning && Game != null) + if (DebugUtils.IsNUnitRunning) { - AddStep("exit game", () => Game.Exit()); - AddUntilStep("wait for game exit", () => Game.Parent == null); + AddStep("exit game", () => Game?.Exit()); + AddUntilStep("wait for game exit", () => Game?.Parent == null); + AddStep("dispose game", () => Game?.Dispose()); } } diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 09cfe5ecad..9b0b66a18c 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -332,6 +332,9 @@ namespace osu.Game.Tests.Visual if (MusicController?.TrackLoaded == true) MusicController.Stop(); + if (realm?.IsValueCreated == true) + Realm.Dispose(); + RecycleLocalStorage(true); } @@ -341,8 +344,6 @@ namespace osu.Game.Tests.Visual { private readonly Track track; - private readonly TrackVirtualStore store; - /// /// Create an instance which creates a for the provided ruleset when requested. /// @@ -372,7 +373,7 @@ namespace osu.Game.Tests.Visual if (referenceClock != null) { - store = new TrackVirtualStore(referenceClock); + var store = new TrackVirtualStore(referenceClock); audio.AddItem(store); track = store.GetVirtual(trackLength); } @@ -385,12 +386,6 @@ namespace osu.Game.Tests.Visual LoadTrack(); } - ~ClockBackedTestWorkingBeatmap() - { - // Remove the track store from the audio manager - store?.Dispose(); - } - protected override Track GetBeatmapTrack() => track; public override bool TryTransferTrack(WorkingBeatmap target) diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index aa8aff3adc..a644936a16 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tests.Visual base.Content.Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); base.Content.Add(new MouseMovementInterceptor { - MouseMoved = updatePlacementTimeAndPosition, + MouseMoved = UpdatePlacementTimeAndPosition, }); } @@ -51,7 +51,9 @@ namespace osu.Game.Tests.Visual protected virtual IBeatmap GetPlayableBeatmap() { - var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + var rulesetInfo = CreateRuleset()!.RulesetInfo; + var playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo); + playable.BeatmapInfo.Ruleset = rulesetInfo; playable.Difficulty.CircleSize = 2; return playable; } @@ -93,13 +95,10 @@ namespace osu.Game.Tests.Visual if (CurrentBlueprint.PlacementActive == PlacementBlueprint.PlacementState.Finished) ResetPlacement(); - updatePlacementTimeAndPosition(); + UpdatePlacementTimeAndPosition(); } - private void updatePlacementTimeAndPosition() => CurrentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(CurrentBlueprint)); - - protected virtual SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint) => - new SnapResult(InputManager.CurrentState.Mouse.Position, null); + protected virtual void UpdatePlacementTimeAndPosition() => CurrentBlueprint.UpdateTimeAndPosition(InputManager.CurrentState.Mouse.Position, 0); public override void Add(Drawable drawable) { @@ -108,7 +107,7 @@ namespace osu.Game.Tests.Visual if (drawable is HitObjectPlacementBlueprint blueprint) { blueprint.Show(); - blueprint.UpdateTimeAndPosition(SnapForBlueprint(blueprint)); + UpdatePlacementTimeAndPosition(); } } diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs index 43d779261c..709ff1d62e 100644 --- a/osu.Game/Tests/Visual/PlayerTestScene.cs +++ b/osu.Game/Tests/Visual/PlayerTestScene.cs @@ -70,14 +70,6 @@ namespace osu.Game.Tests.Visual AddStep($"Load player for {CreatePlayerRuleset().Description}", LoadPlayer); AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); - - if (AllowBackwardsSeeks) - { - AddStep("allow backwards seeking", () => - { - Player.DrawableRuleset.AllowBackwardsSeeks = AllowBackwardsSeeks; - }); - } } protected virtual bool AllowFail => false; diff --git a/osu.Game/Tests/Visual/ReplayStabilityTestScene.cs b/osu.Game/Tests/Visual/ReplayStabilityTestScene.cs new file mode 100644 index 0000000000..a84fb86200 --- /dev/null +++ b/osu.Game/Tests/Visual/ReplayStabilityTestScene.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 System.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Replays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual +{ + /// + /// The goal of this abstract test class is to ensure that the process of exporting and re-importing of a replay does not affect its playback. + /// Use to exercise that property. + /// + [HeadlessTest] + [TestFixture] + public abstract partial class ReplayStabilityTestScene : RateAdjustedBeatmapTestScene + { + private ReplayPlayer currentPlayer = null!; + private readonly List results = new List(); + + /// + /// Runs against the supplied + /// and checks that the judgement results recorded match . + /// Then, encodes the , decodes the result of encoding, runs the result of decoding against the supplied , + /// and checks that the judgement results recorded still match . + /// + protected void RunTest(IBeatmap beatmap, Replay replay, IEnumerable expectedResults) + { + Score originalScore = null!; + Score decodedScore = null!; + + AddStep(@"create replay", () => originalScore = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo() + }); + + AddStep(@"set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(beatmap)); + AddStep(@"set ruleset", () => Ruleset.Value = beatmap.BeatmapInfo.Ruleset); + AddStep(@"push player", () => pushNewPlayer(originalScore)); + + AddUntilStep(@"wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + skipIntroIfPresent(); + AddUntilStep(@"wait for completion", () => currentPlayer.GameplayState.HasCompleted); + AddAssert(@"judgement results before encode are correct", () => results.Select(r => r.Type), () => Is.EquivalentTo(expectedResults)); + + AddStep(@"exit player", () => currentPlayer.Exit()); + + // The incoming beatmap is ruleset-typed in every usage, so the incoming hitobjects will be used as-is rather than being converted. + // Because we'll be re-using the beatmap (thus also the hitobjects), we need to make sure the previous player has been fully disposed. + AddUntilStep("player exited", () => !currentPlayer.IsCurrentScreen()); + AddStep("dispose player", () => currentPlayer.Dispose()); + + AddStep(@"encode and decode score", () => + { + var encoder = new LegacyScoreEncoder(originalScore, beatmap); + + using (var stream = new MemoryStream()) + { + encoder.Encode(stream, leaveOpen: true); + stream.Position = 0; + decodedScore = new TestScoreDecoder(Beatmap.Value).Parse(stream); + } + }); + + AddStep(@"push player", () => pushNewPlayer(decodedScore)); + + AddUntilStep(@"Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + skipIntroIfPresent(); + AddUntilStep(@"Wait for completion", () => currentPlayer.GameplayState.HasCompleted); + AddAssert(@"judgement results after encode are correct", () => results.Select(r => r.Type), () => Is.EquivalentTo(expectedResults)); + } + + private void pushNewPlayer(Score score) + { + var player = new ReplayPlayer(score); + player.OnLoadComplete += _ => + { + player.GameplayState.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == player) + results.Add(result); + }; + }; + LoadScreen(currentPlayer = player); + results.Clear(); + } + + private void skipIntroIfPresent() => + AddStep(@"skip intro if present", () => + { + if (currentPlayer.ChildrenOfType().Single().CurrentTime < 0) + currentPlayer.Seek(0); + }); + + private class TestScoreDecoder : LegacyScoreDecoder + { + private readonly WorkingBeatmap beatmap; + + public TestScoreDecoder(WorkingBeatmap beatmap) + { + this.beatmap = beatmap; + } + + protected override Ruleset GetRuleset(int rulesetId) => beatmap.BeatmapInfo.Ruleset.CreateInstance(); + protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmap; + } + } +} diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index f780b1a8f8..7d28ee1d1d 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Logging; using osu.Framework.Testing; using osu.Game.Graphics; @@ -32,26 +33,42 @@ namespace osu.Game.Tests.Visual protected DialogOverlay DialogOverlay { get; private set; } [Cached] - private ScreenFooter footer; + protected ScreenFooter ScreenFooter { get; private set; } protected ScreenTestScene() { + ScreenStackFooter screenStackFooter; + ScreenFooter.BackReceptor backReceptor; + base.Content.AddRange(new Drawable[] { + backReceptor = new ScreenFooter.BackReceptor(), Stack = new OsuScreenStack { Name = nameof(ScreenTestScene), RelativeSizeAxes = Axes.Both }, - content = new Container { RelativeSizeAxes = Axes.Both }, + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + content = new Container { RelativeSizeAxes = Axes.Both }, + screenStackFooter = new ScreenStackFooter(Stack, backReceptor) + { + BackButtonPressed = () => Stack.Exit() + } + } + }, overlayContent = new Container { RelativeSizeAxes = Axes.Both, Child = DialogOverlay = new DialogOverlay() }, - footer = new ScreenFooter(), }); + ScreenFooter = screenStackFooter.Footer; + Stack.ScreenPushed += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed → {newScreen}"); Stack.ScreenExited += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed ← {newScreen}"); } diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index c9acfa0ee5..b0bfeb0719 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual private Skin legacySkin; private Skin argonSkin; private Skin specialSkin; - private Skin oldSkin; + private Skin retroSkin; [Resolved] private GameHost host { get; set; } @@ -54,7 +54,7 @@ namespace osu.Game.Tests.Visual metricsSkin = new TestLegacySkin(new SkinInfo { Name = "metrics-skin" }, new NamespacedResourceStore(dllStore, "Resources/metrics_skin"), this, true); legacySkin = new DefaultLegacySkin(this); specialSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, new NamespacedResourceStore(dllStore, "Resources/special_skin"), this, true); - oldSkin = new TestLegacySkin(new SkinInfo { Name = "old-skin" }, new NamespacedResourceStore(dllStore, "Resources/old_skin"), this, true); + retroSkin = new RetroSkin(this); } private readonly List createdDrawables = new List(); @@ -70,7 +70,7 @@ namespace osu.Game.Tests.Visual Cell(2).Child = createProvider(metricsSkin, creationFunction, beatmap); Cell(3).Child = createProvider(legacySkin, creationFunction, beatmap); Cell(4).Child = createProvider(specialSkin, creationFunction, beatmap); - Cell(5).Child = createProvider(oldSkin, creationFunction, beatmap); + Cell(5).Child = createProvider(retroSkin, creationFunction, beatmap); } protected IEnumerable CreatedDrawables => createdDrawables; diff --git a/osu.Game/Updater/GitHubRelease.cs b/osu.Game/Updater/GitHubRelease.cs index effabdbc04..9b97dc0994 100644 --- a/osu.Game/Updater/GitHubRelease.cs +++ b/osu.Game/Updater/GitHubRelease.cs @@ -3,7 +3,9 @@ #nullable disable +using System; using System.Collections.Generic; +using System.Text.Json.Serialization; using Newtonsoft.Json; namespace osu.Game.Updater @@ -18,5 +20,11 @@ namespace osu.Game.Updater [JsonProperty("assets")] public List Assets { get; set; } + + [JsonProperty("prerelease")] + public bool Prerelease { get; set; } + + [JsonPropertyName("published_at")] + public DateTime? PublishedAt { get; set; } } } diff --git a/osu.Game/Updater/MobileUpdateNotifier.cs b/osu.Game/Updater/MobileUpdateNotifier.cs index 04b54df3c0..3a290c9a63 100644 --- a/osu.Game/Updater/MobileUpdateNotifier.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -4,13 +4,14 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; +using osu.Game.Configuration; using osu.Game.Online.API; -using osu.Game.Overlays.Notifications; namespace osu.Game.Updater { @@ -20,7 +21,10 @@ namespace osu.Game.Updater /// public partial class MobileUpdateNotifier : UpdateManager { + public override ReleaseStream? FixedReleaseStream => stream; + private string version = null!; + private ReleaseStream stream; [Resolved] private GameHost host { get; set; } = null!; @@ -28,27 +32,30 @@ namespace osu.Game.Updater [BackgroundDependencyLoader] private void load(OsuGameBase game) { - version = game.Version; + version = game.Version.Split('-').First(); + stream = Enum.TryParse(game.Version.Split('-').Last(), true, out ReleaseStream s) ? s : Configuration.ReleaseStream.Lazer; } - protected override async Task PerformUpdateCheck() + protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) { try { - var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); + bool includePrerelease = stream == Configuration.ReleaseStream.Tachyon; - await releases.PerformAsync().ConfigureAwait(false); + OsuJsonWebRequest releasesRequest = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases?per_page=10&page=1"); + await releasesRequest.PerformAsync(cancellationToken).ConfigureAwait(false); - var latest = releases.ResponseObject; + GitHubRelease[] releases = releasesRequest.ResponseObject; + GitHubRelease? latest = releases.OrderByDescending(r => r.PublishedAt).FirstOrDefault(r => includePrerelease || !r.Prerelease); + + if (latest == null) + return false; - // avoid any discrepancies due to build suffixes for now. - // eventually we will want to support release streams and consider these. - version = version.Split('-').First(); string latestTagName = latest.TagName.Split('-').First(); if (latestTagName != version && tryGetBestUrl(latest, out string? url)) { - Notifications.Post(new SimpleNotification + Notifications.Post(new UpdateAvailableNotification(cancellationToken) { 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", diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index f776cd67be..0710797b60 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.Online.API; -using osu.Game.Overlays.Notifications; namespace osu.Game.Updater { @@ -18,36 +17,42 @@ namespace osu.Game.Updater ///
public partial class NoActionUpdateManager : UpdateManager { - private string version; + public override ReleaseStream? FixedReleaseStream => externalReleaseStream; + + private static ReleaseStream? externalReleaseStream => Enum.TryParse(Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_STREAM"), true, out ReleaseStream stream) ? stream : null; + + private string version = string.Empty; [BackgroundDependencyLoader] private void load(OsuGameBase game) { - version = game.Version; + version = game.Version.Split('-').First(); } - protected override async Task PerformUpdateCheck() + protected override async Task PerformUpdateCheck(CancellationToken cancellationToken) { try { - var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); + ReleaseStream stream = externalReleaseStream ?? ReleaseStream.Value; + bool includePrerelease = stream == Configuration.ReleaseStream.Tachyon; - await releases.PerformAsync().ConfigureAwait(false); + OsuJsonWebRequest releasesRequest = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases?per_page=10&page=1"); + await releasesRequest.PerformAsync(cancellationToken).ConfigureAwait(false); - var latest = releases.ResponseObject; + GitHubRelease[] releases = releasesRequest.ResponseObject; + GitHubRelease? latest = releases.OrderByDescending(r => r.PublishedAt).FirstOrDefault(r => includePrerelease || !r.Prerelease); + + if (latest == null) + return false; - // avoid any discrepancies due to build suffixes for now. - // eventually we will want to support release streams and consider these. - version = version.Split('-').First(); string latestTagName = latest.TagName.Split('-').First(); if (latestTagName != version) { - Notifications.Post(new SimpleNotification + Notifications.Post(new UpdateAvailableNotification(cancellationToken) { Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" + "Check with your package manager / provider to bring osu! up-to-date!", - Icon = FontAwesome.Solid.Download, }); return true; diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index c114e3a8d0..c74adc7ee2 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.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.Reflection; +using System.Threading; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Localisation; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Utils; @@ -30,6 +35,8 @@ namespace osu.Game.Updater // only implementations will actually check for updates. GetType() != typeof(UpdateManager); + public virtual ReleaseStream? FixedReleaseStream => null; + [Resolved] private OsuConfigManager config { get; set; } = null!; @@ -39,58 +46,100 @@ namespace osu.Game.Updater [Resolved] protected INotificationOverlay Notifications { get; private set; } = null!; + protected IBindable ReleaseStream => releaseStream; + + private readonly Bindable releaseStream = new Bindable(); + + private CancellationTokenSource updateCancellationSource = new CancellationTokenSource(); + protected override void LoadComplete() { base.LoadComplete(); - Schedule(() => Task.Run(CheckForUpdateAsync)); - string version = game.Version; - string lastVersion = config.Get(OsuSetting.Version); - if (game.IsDeployedBuild && version != lastVersion) + if (game.IsDeployedBuild) { // only show a notification if we've previously saved a version to the config file (ie. not the first run). - if (!string.IsNullOrEmpty(lastVersion)) + if (!string.IsNullOrEmpty(lastVersion) && version != lastVersion) Notifications.Post(new UpdateCompleteNotification(version)); + // make sure the release stream setting matches the build which was just run. + if (FixedReleaseStream != null) + config.SetValue(OsuSetting.ReleaseStream, FixedReleaseStream.Value); + + // notify the user if they're using a build that is not officially sanctioned. if (RuntimeInfo.EntryAssembly.GetCustomAttribute() == null) Notifications.Post(new SimpleNotification { Text = NotificationsStrings.NotOfficialBuild }); } + else + { + // log that this is not an official build, for if users build their own game without an assembly version. + // this is only logged because a notification would be too spammy in local test builds. + Logger.Log(NotificationsStrings.NotOfficialBuild.ToString()); + } // debug / local compilations will reset to a non-release string. // can be useful to check when an install has transitioned between release and otherwise (see OsuConfigManager's migrations). config.SetValue(OsuSetting.Version, version); + + config.BindWith(OsuSetting.ReleaseStream, releaseStream); + releaseStream.BindValueChanged(_ => CheckForUpdate()); + + CheckForUpdate(); } - private readonly object updateTaskLock = new object(); + /// + /// Immediately checks for any available update. + /// + public void CheckForUpdate() + { + CheckForUpdateAsync().FireAndForget(); + } - private Task? updateCheckTask; - - public async Task CheckForUpdateAsync() + /// + /// Immediately checks for any available update. + /// + /// + /// true if any updates are available, false otherwise. + /// May return true if an error occured (there is potentially an update available). + /// + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) => await Task.Run(async () => { if (!CanCheckForUpdate) return false; - Task waitTask; + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - lock (updateTaskLock) - waitTask = (updateCheckTask ??= PerformUpdateCheck()); + // Cancels the last update and closes any existing notifications as stale. + using (var lastCts = Interlocked.Exchange(ref updateCancellationSource, cts)) + await lastCts.CancelAsync().ConfigureAwait(false); - bool hasUpdates = await waitTask.ConfigureAwait(false); - - lock (updateTaskLock) - updateCheckTask = null; - - return hasUpdates; - } + try + { + return await PerformUpdateCheck(cts.Token).ConfigureAwait(false); + } + catch (Exception e) + { + Logger.Log($"{nameof(PerformUpdateCheck)} failed ({e.Message})"); + return true; + } + }, cancellationToken).ConfigureAwait(false); /// /// Performs an asynchronous check for application updates. /// /// Whether any update is waiting. May return true if an error occured (there is potentially an update available). - protected virtual Task PerformUpdateCheck() => Task.FromResult(false); + protected virtual Task PerformUpdateCheck(CancellationToken cancellationToken) => Task.FromResult(false); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + updateCancellationSource.Cancel(); + updateCancellationSource.Dispose(); + } private partial class UpdateCompleteNotification : SimpleNotification { @@ -111,26 +160,20 @@ namespace osu.Game.Updater Activated = delegate { notificationOverlay.Hide(); - changelog.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version); + changelog.ShowBuild(version); return true; }; } } - public partial class UpdateApplicationCompleteNotification : ProgressCompletionNotification + public partial class UpdateDownloadProgressNotification : ProgressNotification { - public UpdateApplicationCompleteNotification() - { - Text = NotificationsStrings.UpdateReadyToInstall; - } - } + private readonly CancellationToken cancellationToken; - public partial class UpdateProgressNotification : ProgressNotification - { - protected override Notification CreateCompletionNotification() => new UpdateApplicationCompleteNotification + public UpdateDownloadProgressNotification(CancellationToken cancellationToken) { - Activated = CompletionClickAction - }; + this.cancellationToken = cancellationToken; + } [BackgroundDependencyLoader] private void load() @@ -149,24 +192,12 @@ namespace osu.Game.Updater }); } - protected override void LoadComplete() + protected override void Update() { - base.LoadComplete(); - StartDownload(); - } + base.Update(); - public override void Close(bool runFlingAnimation) - { - // cancelling updates is not currently supported by the underlying updater. - // only allow dismissing for now. - - switch (State) - { - case ProgressNotificationState.Cancelled: - case ProgressNotificationState.Completed: - base.Close(runFlingAnimation); - break; - } + if (cancellationToken.IsCancellationRequested) + FailDownload(); } public void StartDownload() @@ -181,6 +212,55 @@ namespace osu.Game.Updater State = ProgressNotificationState.Cancelled; Close(false); } + + protected override Notification CreateCompletionNotification() => new UpdateReadyNotification(cancellationToken) + { + Activated = () => + { + if (cancellationToken.IsCancellationRequested) + return true; + + return CompletionClickAction?.Invoke() ?? true; + } + }; + } + + public partial class UpdateReadyNotification : ProgressCompletionNotification + { + private readonly CancellationToken cancellationToken; + + public UpdateReadyNotification(CancellationToken cancellationToken) + { + this.cancellationToken = cancellationToken; + Text = NotificationsStrings.UpdateReadyToInstall; + } + + protected override void Update() + { + base.Update(); + + if (cancellationToken.IsCancellationRequested) + Close(false); + } + } + + public partial class UpdateAvailableNotification : SimpleNotification + { + private readonly CancellationToken cancellationToken; + + public UpdateAvailableNotification(CancellationToken cancellationToken) + { + this.cancellationToken = cancellationToken; + Icon = FontAwesome.Solid.Download; + } + + protected override void Update() + { + base.Update(); + + if (cancellationToken.IsCancellationRequested) + Close(false); + } } } } diff --git a/osu.Game/Users/ConfirmBlockActionDialog.cs b/osu.Game/Users/ConfirmBlockActionDialog.cs new file mode 100644 index 0000000000..9c52f0e844 --- /dev/null +++ b/osu.Game/Users/ConfirmBlockActionDialog.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 osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Users +{ + public partial class ConfirmBlockActionDialog : DangerousActionDialog + { + private readonly APIUser user; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private NotificationOverlay? notifications { get; set; } + + private ConfirmBlockActionDialog(APIUser user, LocalisableString text, Action action) + { + this.user = user; + BodyText = text; + DangerousAction = () => action(this); + } + + public static ConfirmBlockActionDialog Block(APIUser user) => new ConfirmBlockActionDialog(user, ContextMenuStrings.ConfirmBlockUser(user.Username), d => d.toggleBlock(true)); + public static ConfirmBlockActionDialog Unblock(APIUser user) => new ConfirmBlockActionDialog(user, ContextMenuStrings.ConfirmUnblockUser(user.Username), d => d.toggleBlock(false)); + + private void toggleBlock(bool block) + { + APIRequest req = block ? new BlockUserRequest(user.OnlineID) : new UnblockUserRequest(user.OnlineID); + + req.Success += () => + { + api.LocalUserState.UpdateBlocks(); + }; + + req.Failure += e => + { + notifications?.Post(new SimpleNotification + { + Text = e.Message, + Icon = FontAwesome.Solid.Times, + }); + }; + + api.Queue(req); + } + } +} diff --git a/osu.Game/Users/Drawables/ClickableUsername.cs b/osu.Game/Users/Drawables/ClickableUsername.cs new file mode 100644 index 0000000000..74782ed6ed --- /dev/null +++ b/osu.Game/Users/Drawables/ClickableUsername.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.Graphics.Cursor; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Users.Drawables +{ + internal partial class ClickableUsername : OsuHoverContainer, IHasCustomTooltip + { + public ITooltip GetCustomTooltip() => new ClickableAvatar.NoCardTooltip(); + + public APIUser? TooltipContent { get; } + + private readonly APIUser user; + + [Resolved] + private OsuGame? game { get; set; } + + public ClickableUsername(APIUser? user) + { + TooltipContent = this.user = user ?? new GuestUser(); + + AutoSizeAxes = Axes.Both; + + Child = new OsuSpriteText + { + Text = user!.Username, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + }; + + if (user.Id != APIUser.SYSTEM_USER_ID) + Action = openProfile; + } + + private void openProfile() + { + if (user.Id > 1 || !string.IsNullOrEmpty(user.Username)) + game?.ShowUser(user); + } + } +} diff --git a/osu.Game/Users/Drawables/UpdateableTeamFlag.cs b/osu.Game/Users/Drawables/UpdateableTeamFlag.cs new file mode 100644 index 0000000000..517eb589b9 --- /dev/null +++ b/osu.Game/Users/Drawables/UpdateableTeamFlag.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.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.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Users.Drawables +{ + /// + /// A team logo which can update to a new team when needed. + /// + public partial class UpdateableTeamFlag : ModelBackedDrawable + { + public APITeam? Team + { + get => Model; + set + { + Model = value; + Invalidate(Invalidation.Presence); + } + } + + protected override double LoadDelay => 200; + + public UpdateableTeamFlag(APITeam? team = null) + { + Team = team; + + Masking = true; + } + + protected override Drawable? CreateDrawable(APITeam? team) + { + if (team == null) + return Empty(); + + return new TeamFlag(team) { RelativeSizeAxes = Axes.Both }; + } + + // Generally we just want team flags to disappear if the user doesn't have one. + // This also handles fill flow cases and avoids spacing being added for non-displaying flags. + public override bool IsPresent => base.IsPresent && Team != null; + + protected override void Update() + { + base.Update(); + + CornerRadius = DrawHeight / 8; + } + + [LongRunningLoad] + public partial class TeamFlag : CompositeDrawable, IHasTooltip + { + private readonly APITeam team; + + public LocalisableString TooltipText { get; } + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public TeamFlag(APITeam team) + { + this.team = team; + TooltipText = team.Name; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + InternalChildren = new Drawable[] + { + new HoverClickSounds(), + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.FromHex("333"), + }, + new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(team.FlagUrl), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fit, + } + }; + } + + protected override bool OnClick(ClickEvent e) + { + game?.OpenUrlExternally($"{api.Endpoints.WebsiteUrl}/teams/{team.Id}"); + return true; + } + } + } +} diff --git a/osu.Game/Users/ExtendedUserPanel.cs b/osu.Game/Users/ExtendedUserPanel.cs index e33fb7a44e..70a96b730a 100644 --- a/osu.Game/Users/ExtendedUserPanel.cs +++ b/osu.Game/Users/ExtendedUserPanel.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,43 +13,51 @@ using osu.Game.Users.Drawables; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; namespace osu.Game.Users { public abstract partial class ExtendedUserPanel : UserPanel { - public readonly Bindable Status = new Bindable(); + protected TextFlowContainer LastVisitMessage { get; private set; } = null!; - public readonly IBindable Activity = new Bindable(); + private StatusIcon statusIcon = null!; + private StatusText statusMessage = null!; - protected TextFlowContainer LastVisitMessage { get; private set; } + [Resolved] + private MetadataClient? metadata { get; set; } - private StatusIcon statusIcon; - private StatusText statusMessage; + private UserStatus? lastStatus; + private UserActivity? lastActivity; + private DateTimeOffset? lastVisit; protected ExtendedUserPanel(APIUser user) : base(user) { + lastVisit = user.LastVisit; } [BackgroundDependencyLoader] private void load() { BorderColour = ColourProvider?.Light1 ?? Colours.GreyVioletLighter; - - Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value); - Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue); } protected override void LoadComplete() { base.LoadComplete(); - Status.TriggerChange(); + updatePresence(); // Colour should be applied immediately on first load. statusIcon.FinishTransforms(); } + protected override void Update() + { + base.Update(); + updatePresence(); + } + protected Container CreateStatusIcon() => statusIcon = new StatusIcon(); protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren) @@ -70,15 +76,6 @@ namespace osu.Game.Users text.Origin = alignment; text.AutoSizeAxes = Axes.Both; text.Alpha = 0; - - if (User.LastVisit.HasValue) - { - text.AddText(@"Last seen "); - text.AddText(new DrawableDate(User.LastVisit.Value, italic: false) - { - Shadow = false - }); - } })); statusContainer.Add(statusMessage = new StatusText @@ -91,37 +88,48 @@ namespace osu.Game.Users return statusContainer; } - private void displayStatus(UserStatus? status, UserActivity activity = null) + private void updatePresence() { - if (status != null) + // TODO: we probably don't want to do this every frame. + UserPresence? presence = metadata?.GetPresence(User.OnlineID); + UserStatus status = presence?.Status ?? UserStatus.Offline; + UserActivity? activity = presence?.Activity; + + if (status == lastStatus && activity == lastActivity) + return; + + if (status == UserStatus.Offline && lastVisit != null) { - LastVisitMessage.FadeTo(status == UserStatus.Offline && User.LastVisit.HasValue ? 1 : 0); - - // Set status message based on activity (if we have one) and status is not offline - if (activity != null && status != UserStatus.Offline) + LastVisitMessage.FadeTo(1); + LastVisitMessage.Clear(); + LastVisitMessage.AddText(@"Last seen "); + LastVisitMessage.AddText(new DrawableDate(lastVisit.Value, italic: false) { - statusMessage.Text = activity.GetStatus(); - statusMessage.TooltipText = activity.GetDetails(); - statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint); - return; - } + Shadow = false + }); + } + else + LastVisitMessage.FadeTo(0); - // Otherwise use only status + if (activity == null || status == UserStatus.Offline) + { statusMessage.Text = status.GetLocalisableDescription(); statusMessage.TooltipText = string.Empty; - statusIcon.FadeColour(status.Value.GetAppropriateColour(Colours), 500, Easing.OutQuint); - - return; } - - // Fallback to web status if local one is null - if (User.IsOnline) + else { - Status.Value = UserStatus.Online; - return; + statusMessage.Text = activity.GetStatus(); + statusMessage.TooltipText = activity.GetDetails() ?? string.Empty; } - Status.Value = UserStatus.Offline; + if (activity == null || status != UserStatus.Online) + statusIcon.FadeColour(status.GetAppropriateColour(Colours), 500, Easing.OutQuint); + else + statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint); + + lastStatus = status; + lastActivity = activity; + lastVisit = status != UserStatus.Offline ? DateTimeOffset.Now : lastVisit; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index a8e0fc9030..86c84c0bb2 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -6,6 +6,7 @@ using MessagePack; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -34,6 +35,8 @@ namespace osu.Game.Users [Union(41, typeof(EditingBeatmap))] [Union(42, typeof(ModdingBeatmap))] [Union(43, typeof(TestingBeatmap))] + [Union(51, typeof(InDailyChallengeLobby))] + [Union(52, typeof(PlayingDailyChallenge))] public abstract class UserActivity { public abstract string GetStatus(bool hideIdentifiableInformation = false); @@ -54,6 +57,11 @@ namespace osu.Game.Users } [MessagePackObject] + [Union(12, typeof(InSoloGame))] + [Union(23, typeof(InMultiplayerGame))] + [Union(24, typeof(SpectatingMultiplayerGame))] + [Union(31, typeof(InPlaylistGame))] + [Union(52, typeof(PlayingDailyChallenge))] public abstract class InGame : UserActivity { [Key(0)] @@ -240,7 +248,7 @@ namespace osu.Game.Users [SerializationConstructor] public SpectatingMultiplayerGame() { } - public override string GetStatus(bool hideIdentifiableInformation = false) => $"Watching others {base.GetStatus(hideIdentifiableInformation).ToLowerInvariant()}"; + public override string GetStatus(bool hideIdentifiableInformation = false) => @"Spectating a multiplayer game"; } [MessagePackObject] @@ -264,6 +272,20 @@ namespace osu.Game.Users RoomName = room.Name; } + public InLobby(MultiplayerRoom room) + { + if (room.Settings.MatchType == MatchType.Matchmaking) + { + RoomID = -1; + RoomName = "Quick Play"; + } + else + { + RoomID = room.RoomID; + RoomName = room.Settings.Name; + } + } + [SerializationConstructor] public InLobby() { } @@ -273,5 +295,30 @@ namespace osu.Game.Users ? null : RoomName; } + + [MessagePackObject] + public class InDailyChallengeLobby : UserActivity + { + [SerializationConstructor] + public InDailyChallengeLobby() { } + + public override string GetStatus(bool hideIdentifiableInformation = false) => @"In daily challenge lobby"; + } + + [MessagePackObject] + public class PlayingDailyChallenge : InGame + { + public PlayingDailyChallenge(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) + : base(beatmapInfo, ruleset) + { + } + + [SerializationConstructor] + public PlayingDailyChallenge() + { + } + + public override string GetStatus(bool hideIdentifiableInformation = false) => @$"{RulesetPlayingVerb} in daily challenge"; + } } } diff --git a/osu.Game/Users/UserCoverBackground.cs b/osu.Game/Users/UserCoverBackground.cs index de6a306b2a..4d248d450b 100644 --- a/osu.Game/Users/UserCoverBackground.cs +++ b/osu.Game/Users/UserCoverBackground.cs @@ -33,7 +33,10 @@ namespace osu.Game.Users protected virtual double UnloadDelay => 5000; protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) - => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay); + => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay) + { + RelativeSizeAxes = Axes.Both, + }; [LongRunningLoad] private partial class Cover : CompositeDrawable diff --git a/osu.Game/Users/UserGridPanel.cs b/osu.Game/Users/UserGridPanel.cs index fce543415d..f62c9ab4e7 100644 --- a/osu.Game/Users/UserGridPanel.cs +++ b/osu.Game/Users/UserGridPanel.cs @@ -82,9 +82,10 @@ namespace osu.Game.Users AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(6), - Children = new Drawable[] + Children = new[] { CreateFlag(), + CreateTeamLogo(), // supporter icon is being added later } } diff --git a/osu.Game/Users/UserListPanel.cs b/osu.Game/Users/UserListPanel.cs index 4942cc7512..77ff13f260 100644 --- a/osu.Game/Users/UserListPanel.cs +++ b/osu.Game/Users/UserListPanel.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Graphics; using osu.Framework.Allocation; using osu.Framework.Graphics.Colour; @@ -26,6 +27,8 @@ namespace osu.Game.Users [BackgroundDependencyLoader] private void load() { + Debug.Assert(Background != null); + Background.Width = 0.5f; Background.Origin = Anchor.CentreRight; Background.Anchor = Anchor.CentreRight; diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 0d3ea52611..822eac7258 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -22,6 +22,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; using osu.Game.Localisation; +using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; using osu.Game.Screens; using osu.Game.Screens.Play; @@ -64,6 +65,9 @@ namespace osu.Game.Users [Resolved] private ChatOverlay? chatOverlay { get; set; } + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] protected OverlayColourProvider? ColourProvider { get; private set; } @@ -76,6 +80,9 @@ namespace osu.Game.Users [Resolved] private MultiplayerClient? multiplayerClient { get; set; } + [Resolved] + private MetadataClient? metadataClient { get; set; } + [BackgroundDependencyLoader] private void load() { @@ -130,6 +137,11 @@ namespace osu.Game.Users Action = Action, }; + protected Drawable CreateTeamLogo() => new UpdateableTeamFlag(User.Team) + { + Size = new Vector2(52, 26), + }; + public MenuItem[] ContextMenuItems { get @@ -148,20 +160,33 @@ namespace osu.Game.Users chatOverlay?.Show(); })); - if (User.IsOnline) + items.Add(!isUserBlocked() + ? new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Block(User))) + : new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Unblock(User)))); + + if (isUserOnline()) { items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => { - performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))); + if (isUserOnline()) + performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))); })); - if (multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true) + if (canInviteUser()) { - items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => multiplayerClient.InvitePlayer(User.Id))); + items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () => + { + if (canInviteUser()) + multiplayerClient!.InvitePlayer(User.Id); + })); } } return items.ToArray(); + + bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null; + bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true; + bool isUserBlocked() => api.LocalUserState.Blocks.Any(b => b.TargetID == User.OnlineID); } } diff --git a/osu.Game/Users/UserRankPanel.cs b/osu.Game/Users/UserRankPanel.cs index 5e3ae172be..251c21a89a 100644 --- a/osu.Game/Users/UserRankPanel.cs +++ b/osu.Game/Users/UserRankPanel.cs @@ -27,7 +27,7 @@ namespace osu.Game.Users private const int padding = 10; private const int main_content_height = 80; - private ProfileValueDisplay globalRankDisplay = null!; + private GlobalRankDisplay globalRankDisplay = null!; private ProfileValueDisplay countryRankDisplay = null!; private LoadingLayer loadingLayer = null!; @@ -71,8 +71,13 @@ namespace osu.Game.Users var statistics = statisticsProvider?.GetStatisticsFor(ruleset.Value); loadingLayer.State.Value = statistics == null ? Visibility.Visible : Visibility.Hidden; - globalRankDisplay.Content = statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? "-"; - countryRankDisplay.Content = statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? "-"; + + // TODO: implement highest rank tooltip + // `RankHighest` resides in `APIUser`, but `api.LocalUser` doesn't update + // maybe move to `UserStatistics` in api, so `UserStatisticsWatcher` can update the value + globalRankDisplay.UserStatistics.Value = statistics; + + countryRankDisplay.Content.Text = statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? "-"; } protected override Drawable CreateLayout() @@ -147,9 +152,10 @@ namespace osu.Game.Users AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(6), - Children = new Drawable[] + Children = new[] { CreateFlag(), + CreateTeamLogo(), // supporter icon is being added later } } @@ -186,13 +192,7 @@ namespace osu.Game.Users { new Drawable[] { - globalRankDisplay = new ProfileValueDisplay(true) - { - Title = UsersStrings.ShowRankGlobalSimple, - // TODO: implement highest rank tooltip - // `RankHighest` resides in `APIUser`, but `api.LocalUser` doesn't update - // maybe move to `UserStatistics` in api, so `UserStatisticsWatcher` can update the value - }, + globalRankDisplay = new GlobalRankDisplay(), countryRankDisplay = new ProfileValueDisplay(true) { Title = UsersStrings.ShowRankCountrySimple, diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 918a1b6968..65bea41e20 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -4,9 +4,14 @@ #nullable disable using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using JetBrains.Annotations; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osu.Game.Utils; @@ -35,6 +40,9 @@ namespace osu.Game.Users [JsonProperty(@"global_rank")] public int? GlobalRank; + [JsonProperty(@"global_rank_percent")] + public float? GlobalRankPercent; + [JsonProperty(@"country_rank")] public int? CountryRank; @@ -74,6 +82,10 @@ namespace osu.Game.Users [JsonProperty(@"grade_counts")] public Grades GradesCount; + [JsonProperty(@"variants")] + [CanBeNull] + public List Variants; + public struct Grades { [JsonProperty(@"ssh")] @@ -118,5 +130,35 @@ namespace osu.Game.Users } } } + + public enum RulesetVariant + { + [EnumMember(Value = "4k")] + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.VariantMania4k))] + FourKey, + + [EnumMember(Value = "7k")] + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.VariantMania7k))] + SevenKey + } + + public class Variant + { + [JsonProperty("country_rank")] + public int? CountryRank; + + [JsonProperty("global_rank")] + public int? GlobalRank; + + [JsonProperty("mode")] + public string Mode; + + [JsonProperty("pp")] + public decimal PP; + + [JsonProperty("variant")] + [JsonConverter(typeof(StringEnumConverter))] + public RulesetVariant VariantType; + } } } diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index cccad3711c..29e402144a 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -10,6 +10,12 @@ namespace osu.Game.Utils { public static class FormatUtils { + public static double FloorToDecimalDigits(this double value, uint digits) + { + double base10 = Math.Pow(10, digits); + return Math.Floor(value * base10) / base10; + } + /// /// Turns the provided accuracy into a percentage with 2 decimal places. /// @@ -21,16 +27,27 @@ namespace osu.Game.Utils // ie. a score which gets 89.99999% shouldn't ever show as 90%. // the reasoning for this is that cutoffs for grade increases are at whole numbers and displaying the required // percentile with a non-matching grade is confusing. - accuracy = Math.Floor(accuracy * 10000) / 10000; - - return accuracy.ToLocalisableString("0.00%"); + return accuracy.FloorToDecimalDigits(4).ToLocalisableString("0.00%"); } /// /// Formats the supplied rank/leaderboard position in a consistent, simplified way. /// /// The rank/position to be formatted. - public static string FormatRank(this int rank) => rank.ToMetric(decimals: rank < 100_000 ? 1 : 0); + public static string FormatRank(this int rank) => rank.ToMetric(decimals: rank < 10_000 ? 1 : 0); + + /// + /// Formats the supplied star rating in a consistent, simplified way. + /// + /// The star rating to be formatted. + public static LocalisableString FormatStarRating(this double starRating) + { + // for the sake of display purposes, we don't want to show a user a "rounded up" star rating to the next whole number. + // i.e. a beatmap which has a star rating of 6.9999* should never show as 7.00*. + // this matters for star rating medals which use hard cutoffs at whole numbers, + // which then confuses users when they beat a 6.9999* beatmap but don't get the 7-star medal. + return starRating.FloorToDecimalDigits(2).ToLocalisableString("0.00"); + } /// /// Finds the number of digits after the decimal. @@ -53,11 +70,8 @@ namespace osu.Game.Utils /// /// Applies rounding to the given BPM value. /// - /// - /// Double-rounding is applied intentionally (see https://github.com/ppy/osu/pull/18345#issue-1243311382 for rationale). - /// /// The base BPM to round. /// Rate adjustment, if applicable. - public static int RoundBPM(double baseBpm, double rate = 1) => (int)Math.Round(Math.Round(baseBpm) * rate); + public static int RoundBPM(double baseBpm, double rate = 1) => (int)Math.Round(baseBpm * rate); } } diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index eac86a9c02..185b1cc4f1 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -144,8 +144,9 @@ namespace osu.Game.Utils /// Returns a gamefield-space quad surrounding the provided hit objects. /// /// The hit objects to calculate a quad for. - public static Quad GetSurroundingQuad(IEnumerable hitObjects) => - GetSurroundingQuad(enumerateStartAndEndPositions(hitObjects)); + /// Whether to only include the start and end positions of the slider, or include every control point in the slider. + public static Quad GetSurroundingQuad(IEnumerable hitObjects, bool startAndEndOnly = false) => + GetSurroundingQuad(startAndEndOnly ? enumerateStartAndEndPositions(hitObjects) : enumeratePositions(hitObjects)); /// /// Returns the points that make up the convex hull of the provided points. @@ -202,7 +203,7 @@ namespace osu.Game.Utils } public static List GetConvexHull(IEnumerable hitObjects) => - GetConvexHull(enumerateStartAndEndPositions(hitObjects)); + GetConvexHull(enumeratePositions(hitObjects)); private static IEnumerable enumerateStartAndEndPositions(IEnumerable hitObjects) => hitObjects.SelectMany(h => @@ -220,6 +221,17 @@ namespace osu.Game.Utils return new[] { h.Position }; }); + private static IEnumerable enumeratePositions(IEnumerable hitObjects) => + hitObjects.SelectMany(h => + { + if (h is IHasPath path) + { + return path.Path.ControlPoints.Select(p => h.Position + p.Position); + } + + return new[] { h.Position }; + }); + #region Welzl helpers // Function to check whether a point lies inside or on the boundaries of the circle diff --git a/osu.Game/Utils/MobileUtils.cs b/osu.Game/Utils/MobileUtils.cs new file mode 100644 index 0000000000..6e59efb71c --- /dev/null +++ b/osu.Game/Utils/MobileUtils.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.Game.Screens; +using osu.Game.Screens.Play; + +namespace osu.Game.Utils +{ + public static class MobileUtils + { + /// + /// Determines the correct state which a mobile device should be put into for the given information. + /// + /// Information about whether the user is currently playing. + /// The current screen which the user is at. + /// Whether the user is playing on a mobile tablet device instead of a phone. + public static Orientation GetOrientation(ILocalUserPlayInfo userPlayInfo, IOsuScreen currentScreen, bool isTablet) + { + bool lockCurrentOrientation = userPlayInfo.PlayingState.Value == LocalUserPlayingState.Playing; + bool lockToPortraitOnPhone = currentScreen.RequiresPortraitOrientation; + + if (lockToPortraitOnPhone && !isTablet) + return Orientation.Portrait; + + if (lockCurrentOrientation) + return Orientation.Locked; + + return Orientation.Default; + } + + public enum Orientation + { + /// + /// Lock the game orientation. + /// + Locked, + + /// + /// Lock the game to portrait orientation (does not include upside-down portrait). + /// + Portrait, + + /// + /// Use the application's default settings. + /// + Default, + } + } +} diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 15fc34b468..e944b188f1 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -127,12 +128,13 @@ namespace osu.Game.Utils } /// - /// Checks that all s in a combination are valid as "required mods" in a multiplayer match session. + /// Checks whether the given combination of mods may be set as the required mods of a multiplayer playlist item. /// /// The mods to check. + /// Whether freestyle is enabled for the playlist item. /// Invalid mods, if any were found. Will be null if all mods were valid. /// Whether the input mods were all valid. If false, will contain all invalid entries. - public static bool CheckValidRequiredModsForMultiplayer(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods) + public static bool CheckValidRequiredModsForMultiplayer(IEnumerable mods, bool freestyle, [NotNullWhen(false)] out List? invalidMods) { mods = mods.ToArray(); @@ -144,11 +146,11 @@ namespace osu.Game.Utils if (!CheckCompatibleSet(mods, out invalidMods)) return false; - return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer, out invalidMods); + return checkValid(mods, m => IsValidModForMatch(m, true, MatchType.HeadToHead, freestyle), out invalidMods); } /// - /// Checks that all s in a combination are valid as "free mods" in a multiplayer match session. + /// Checks whether the given mods are valid to appear as allowed mods in a multiplayer playlist item. /// /// /// Note that this does not check compatibility between mods, @@ -156,10 +158,11 @@ namespace osu.Game.Utils /// not to be confused with the list of mods the user currently has selected for the multiplayer match. /// /// The mods to check. + /// Whether freestyle is enabled for the playlist item. /// Invalid mods, if any were found. Will be null if all mods were valid. /// Whether the input mods were all valid. If false, will contain all invalid entries. - public static bool CheckValidFreeModsForMultiplayer(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods) - => checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayerAsFreeMod && !(m is MultiMod), out invalidMods); + public static bool CheckValidAllowedModsForMultiplayer(IEnumerable mods, bool freestyle, [NotNullWhen(false)] out List? invalidMods) + => checkValid(mods, m => IsValidModForMatch(m, false, MatchType.HeadToHead, freestyle), out invalidMods); private static bool checkValid(IEnumerable mods, Predicate valid, [NotNullWhen(false)] out List? invalidMods) { @@ -292,5 +295,61 @@ namespace osu.Game.Utils return rate; } + + /// + /// Determines whether a given mod is valid on a playlist item. + /// + /// The mod to test. + /// + /// true if the mod is intended as a required mod on the target playlist item. + /// false if it is intended as an allowed mod. + /// + /// The type of match being played. + /// Whether the target playlist item enables freestyle mode. + /// Related osu!web function. + public static bool IsValidModForMatch(Mod mod, bool required, MatchType matchType, bool freestyle) + { + if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) + return false; + + if (freestyle && required && !mod.ValidForFreestyleAsRequiredMod) + return false; + + switch (matchType) + { + case MatchType.Playlists: + return true; + + default: + return required ? mod.ValidForMultiplayer : mod.ValidForMultiplayerAsFreeMod; + } + } + + /// + /// Given an online listing of mods and the user's preferred ruleset, gathers the mods which are selectable as free mods by the current user. + /// + /// The type of match being played. + /// The required mods for the playlist item. + /// The allowed mods for the playlist item. + /// Whether freestyle is enabled for the playlist item. + /// The user's preferred ruleset, which may differ from the playlist item's selection on freestyle playlist items. + public static Mod[] EnumerateUserSelectableFreeMods(MatchType matchType, IEnumerable requiredMods, IEnumerable allowedMods, bool freestyle, Ruleset userRuleset) + { + if (freestyle) + { + Mod[] rulesetRequiredMods = requiredMods.Select(m => m.ToMod(userRuleset)).ToArray(); + + // In freestyle, the playlist item doesn't provide the allowed mods. Instead, all mods are unconditionally allowed by default. + return userRuleset.AllMods.OfType() + // But the mods must still be compatible with the room... + .Where(m => IsValidModForMatch(m, false, matchType, true)) + // ... And compatible with the required mods listing (this also handles de-duplication). + .Where(m => CheckCompatibleSet(rulesetRequiredMods.Append(m))) + .ToArray(); + } + + // Without freestyle, only the mods specified by the playlist item are valid. + return allowedMods.Select(m => m.ToMod(userRuleset)).ToArray(); + } } } diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 8d3e5fb834..4f916f810e 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Framework.Statistics; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -36,11 +37,11 @@ namespace osu.Game.Utils private readonly OsuGame game; - public SentryLogger(OsuGame game) + public SentryLogger(OsuGame game, Storage? storage = null) { this.game = game; - if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteRootUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal)) + if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal)) return; sentrySession = SentrySdk.Init(options => @@ -49,14 +50,18 @@ namespace osu.Game.Utils options.AutoSessionTracking = true; options.IsEnvironmentUser = false; options.IsGlobalModeEnabled = true; + options.CacheDirectoryPath = storage?.GetFullPath(string.Empty); // The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml - options.Release = $"osu@{game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty)}"; + options.Release = $"osu@{game.Version.Split('-').First()}"; }); Logger.NewEntry += processLogEntry; } - ~SentryLogger() => Dispose(false); + ~SentryLogger() + { + Dispose(false); + } public void AttachUser(IBindable user) { diff --git a/osu.Game/Utils/SpecialFunctions.cs b/osu.Game/Utils/SpecialFunctions.cs deleted file mode 100644 index 795a84a973..0000000000 --- a/osu.Game/Utils/SpecialFunctions.cs +++ /dev/null @@ -1,691 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -// All code is referenced from the following: -// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs -// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs - -/* - Copyright (c) 2002-2022 Math.NET -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -using System; - -namespace osu.Game.Utils -{ - public class SpecialFunctions - { - private const double sqrt2_pi = 2.5066282746310005024157652848110452530069867406099d; - - /// - /// ************************************** - /// COEFFICIENTS FOR METHOD ErfImp * - /// ************************************** - /// - /// Polynomial coefficients for a numerator of ErfImp - /// calculation for Erf(x) in the interval [1e-10, 0.5]. - /// - private static readonly double[] erf_imp_an = { 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 }; - - /// Polynomial coefficients for a denominator of ErfImp - /// calculation for Erf(x) in the interval [1e-10, 0.5]. - /// - private static readonly double[] erf_imp_ad = { 1, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [0.5, 0.75]. - /// - private static readonly double[] erf_imp_bn = { -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [0.5, 0.75]. - /// - private static readonly double[] erf_imp_bd = { 1, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [0.75, 1.25]. - /// - private static readonly double[] erf_imp_cn = { -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [0.75, 1.25]. - /// - private static readonly double[] erf_imp_cd = { 1, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [1.25, 2.25]. - /// - private static readonly double[] erf_imp_dn = { -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [1.25, 2.25]. - /// - private static readonly double[] erf_imp_dd = { 1, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [2.25, 3.5]. - /// - private static readonly double[] erf_imp_en = { -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [2.25, 3.5]. - /// - private static readonly double[] erf_imp_ed = { 1, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [3.5, 5.25]. - /// - private static readonly double[] erf_imp_fn = { -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [3.5, 5.25]. - /// - private static readonly double[] erf_imp_fd = { 1, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [5.25, 8]. - /// - private static readonly double[] erf_imp_gn = { -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [5.25, 8]. - /// - private static readonly double[] erf_imp_gd = { 1, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [8, 11.5]. - /// - private static readonly double[] erf_imp_hn = { -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [8, 11.5]. - /// - private static readonly double[] erf_imp_hd = { 1, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [11.5, 17]. - /// - private static readonly double[] erf_imp_in = { -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [11.5, 17]. - /// - private static readonly double[] erf_imp_id = { 1, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [17, 24]. - /// - private static readonly double[] erf_imp_jn = { -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [17, 24]. - /// - private static readonly double[] erf_imp_jd = { 1, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [24, 38]. - /// - private static readonly double[] erf_imp_kn = { -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [24, 38]. - /// - private static readonly double[] erf_imp_kd = { 1, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [38, 60]. - /// - private static readonly double[] erf_imp_ln = { -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [38, 60]. - /// - private static readonly double[] erf_imp_ld = { 1, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [60, 85]. - /// - private static readonly double[] erf_imp_mn = { -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [60, 85]. - /// - private static readonly double[] erf_imp_md = { 1, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 }; - - /// Polynomial coefficients for a numerator in ErfImp - /// calculation for Erfc(x) in the interval [85, 110]. - /// - private static readonly double[] erf_imp_nn = { -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 }; - - /// Polynomial coefficients for a denominator in ErfImp - /// calculation for Erfc(x) in the interval [85, 110]. - /// - private static readonly double[] erf_imp_nd = { 1, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 }; - - /// - /// ************************************** - /// COEFFICIENTS FOR METHOD ErfInvImp * - /// ************************************** - /// - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0, 0.5]. - /// - private static readonly double[] erv_inv_imp_an = { -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0, 0.5]. - /// - private static readonly double[] erv_inv_imp_ad = { 1, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. - /// - private static readonly double[] erv_inv_imp_bn = { -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. - /// - private static readonly double[] erv_inv_imp_bd = { 1, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. - /// - private static readonly double[] erv_inv_imp_cn = { -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. - /// - private static readonly double[] erv_inv_imp_cd = { 1, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. - /// - private static readonly double[] erv_inv_imp_dn = { -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. - /// - private static readonly double[] erv_inv_imp_dd = { 1, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. - /// - private static readonly double[] erv_inv_imp_en = { -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. - /// - private static readonly double[] erv_inv_imp_ed = { 1, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. - /// - private static readonly double[] erv_inv_imp_fn = { -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. - /// - private static readonly double[] erv_inv_imp_fd = { 1, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 }; - - /// Polynomial coefficients for a numerator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. - /// - private static readonly double[] erv_inv_imp_gn = { -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 }; - - /// Polynomial coefficients for a denominator of ErfInvImp - /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. - /// - private static readonly double[] erv_inv_imp_gd = { 1, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 }; - - /// Calculates the error function. - /// The value to evaluate. - /// the error function evaluated at given value. - /// - /// - /// returns 1 if x == double.PositiveInfinity. - /// returns -1 if x == double.NegativeInfinity. - /// - /// - public static double Erf(double x) - { - if (x == 0) - { - return 0; - } - - if (double.IsPositiveInfinity(x)) - { - return 1; - } - - if (double.IsNegativeInfinity(x)) - { - return -1; - } - - if (double.IsNaN(x)) - { - return double.NaN; - } - - return erfImp(x, false); - } - - /// Calculates the complementary error function. - /// The value to evaluate. - /// the complementary error function evaluated at given value. - /// - /// - /// returns 0 if x == double.PositiveInfinity. - /// returns 2 if x == double.NegativeInfinity. - /// - /// - public static double Erfc(double x) - { - if (x == 0) - { - return 1; - } - - if (double.IsPositiveInfinity(x)) - { - return 0; - } - - if (double.IsNegativeInfinity(x)) - { - return 2; - } - - if (double.IsNaN(x)) - { - return double.NaN; - } - - return erfImp(x, true); - } - - /// Calculates the inverse error function evaluated at z. - /// The inverse error function evaluated at given value. - /// - /// - /// returns double.PositiveInfinity if z >= 1.0. - /// returns double.NegativeInfinity if z <= -1.0. - /// - /// - /// Calculates the inverse error function evaluated at z. - /// value to evaluate. - /// the inverse error function evaluated at Z. - public static double ErfInv(double z) - { - if (z == 0.0) - { - return 0.0; - } - - if (z >= 1.0) - { - return double.PositiveInfinity; - } - - if (z <= -1.0) - { - return double.NegativeInfinity; - } - - double p, q, s; - - if (z < 0) - { - p = -z; - q = 1 - p; - s = -1; - } - else - { - p = z; - q = 1 - z; - s = 1; - } - - return erfInvImpl(p, q, s); - } - - /// - /// Implementation of the error function. - /// - /// Where to evaluate the error function. - /// Whether to compute 1 - the error function. - /// the error function. - private static double erfImp(double z, bool invert) - { - if (z < 0) - { - if (!invert) - { - return -erfImp(-z, false); - } - - if (z < -0.5) - { - return 2 - erfImp(-z, true); - } - - return 1 + erfImp(-z, false); - } - - double result; - - // Big bunch of selection statements now to pick which - // implementation to use, try to put most likely options - // first: - if (z < 0.5) - { - // We're going to calculate erf: - if (z < 1e-10) - { - result = (z * 1.125) + (z * 0.003379167095512573896158903121545171688); - } - else - { - // Worst case absolute error found: 6.688618532e-21 - result = (z * 1.125) + (z * evaluatePolynomial(z, erf_imp_an) / evaluatePolynomial(z, erf_imp_ad)); - } - } - else if (z < 110) - { - // We'll be calculating erfc: - invert = !invert; - double r, b; - - if (z < 0.75) - { - // Worst case absolute error found: 5.582813374e-21 - r = evaluatePolynomial(z - 0.5, erf_imp_bn) / evaluatePolynomial(z - 0.5, erf_imp_bd); - b = 0.3440242112F; - } - else if (z < 1.25) - { - // Worst case absolute error found: 4.01854729e-21 - r = evaluatePolynomial(z - 0.75, erf_imp_cn) / evaluatePolynomial(z - 0.75, erf_imp_cd); - b = 0.419990927F; - } - else if (z < 2.25) - { - // Worst case absolute error found: 2.866005373e-21 - r = evaluatePolynomial(z - 1.25, erf_imp_dn) / evaluatePolynomial(z - 1.25, erf_imp_dd); - b = 0.4898625016F; - } - else if (z < 3.5) - { - // Worst case absolute error found: 1.045355789e-21 - r = evaluatePolynomial(z - 2.25, erf_imp_en) / evaluatePolynomial(z - 2.25, erf_imp_ed); - b = 0.5317370892F; - } - else if (z < 5.25) - { - // Worst case absolute error found: 8.300028706e-22 - r = evaluatePolynomial(z - 3.5, erf_imp_fn) / evaluatePolynomial(z - 3.5, erf_imp_fd); - b = 0.5489973426F; - } - else if (z < 8) - { - // Worst case absolute error found: 1.700157534e-21 - r = evaluatePolynomial(z - 5.25, erf_imp_gn) / evaluatePolynomial(z - 5.25, erf_imp_gd); - b = 0.5571740866F; - } - else if (z < 11.5) - { - // Worst case absolute error found: 3.002278011e-22 - r = evaluatePolynomial(z - 8, erf_imp_hn) / evaluatePolynomial(z - 8, erf_imp_hd); - b = 0.5609807968F; - } - else if (z < 17) - { - // Worst case absolute error found: 6.741114695e-21 - r = evaluatePolynomial(z - 11.5, erf_imp_in) / evaluatePolynomial(z - 11.5, erf_imp_id); - b = 0.5626493692F; - } - else if (z < 24) - { - // Worst case absolute error found: 7.802346984e-22 - r = evaluatePolynomial(z - 17, erf_imp_jn) / evaluatePolynomial(z - 17, erf_imp_jd); - b = 0.5634598136F; - } - else if (z < 38) - { - // Worst case absolute error found: 2.414228989e-22 - r = evaluatePolynomial(z - 24, erf_imp_kn) / evaluatePolynomial(z - 24, erf_imp_kd); - b = 0.5638477802F; - } - else if (z < 60) - { - // Worst case absolute error found: 5.896543869e-24 - r = evaluatePolynomial(z - 38, erf_imp_ln) / evaluatePolynomial(z - 38, erf_imp_ld); - b = 0.5640528202F; - } - else if (z < 85) - { - // Worst case absolute error found: 3.080612264e-21 - r = evaluatePolynomial(z - 60, erf_imp_mn) / evaluatePolynomial(z - 60, erf_imp_md); - b = 0.5641309023F; - } - else - { - // Worst case absolute error found: 8.094633491e-22 - r = evaluatePolynomial(z - 85, erf_imp_nn) / evaluatePolynomial(z - 85, erf_imp_nd); - b = 0.5641584396F; - } - - double g = Math.Exp(-z * z) / z; - result = (g * b) + (g * r); - } - else - { - // Any value of z larger than 28 will underflow to zero: - result = 0; - invert = !invert; - } - - if (invert) - { - result = 1 - result; - } - - return result; - } - - /// Calculates the complementary inverse error function evaluated at z. - /// The complementary inverse error function evaluated at given value. - /// We have tested this implementation against the arbitrary precision mpmath library - /// and found cases where we can only guarantee 9 significant figures correct. - /// - /// returns double.PositiveInfinity if z <= 0.0. - /// returns double.NegativeInfinity if z >= 2.0. - /// - /// - /// calculates the complementary inverse error function evaluated at z. - /// value to evaluate. - /// the complementary inverse error function evaluated at Z. - public static double ErfcInv(double z) - { - if (z <= 0.0) - { - return double.PositiveInfinity; - } - - if (z >= 2.0) - { - return double.NegativeInfinity; - } - - double p, q, s; - - if (z > 1) - { - q = 2 - z; - p = 1 - q; - s = -1; - } - else - { - p = 1 - z; - q = z; - s = 1; - } - - return erfInvImpl(p, q, s); - } - - /// - /// The implementation of the inverse error function. - /// - /// First intermediate parameter. - /// Second intermediate parameter. - /// Third intermediate parameter. - /// the inverse error function. - private static double erfInvImpl(double p, double q, double s) - { - double result; - - if (p <= 0.5) - { - // Evaluate inverse erf using the rational approximation: - // - // x = p(p+10)(Y+R(p)) - // - // Where Y is a constant, and R(p) is optimized for a low - // absolute error compared to |Y|. - // - // double: Max error found: 2.001849e-18 - // long double: Max error found: 1.017064e-20 - // Maximum Deviation Found (actual error term at infinite precision) 8.030e-21 - const float y = 0.0891314744949340820313f; - double g = p * (p + 10); - double r = evaluatePolynomial(p, erv_inv_imp_an) / evaluatePolynomial(p, erv_inv_imp_ad); - result = (g * y) + (g * r); - } - else if (q >= 0.25) - { - // Rational approximation for 0.5 > q >= 0.25 - // - // x = sqrt(-2*log(q)) / (Y + R(q)) - // - // Where Y is a constant, and R(q) is optimized for a low - // absolute error compared to Y. - // - // double : Max error found: 7.403372e-17 - // long double : Max error found: 6.084616e-20 - // Maximum Deviation Found (error term) 4.811e-20 - const float y = 2.249481201171875f; - double g = Math.Sqrt(-2 * Math.Log(q)); - double xs = q - 0.25; - double r = evaluatePolynomial(xs, erv_inv_imp_bn) / evaluatePolynomial(xs, erv_inv_imp_bd); - result = g / (y + r); - } - else - { - // For q < 0.25 we have a series of rational approximations all - // of the general form: - // - // let: x = sqrt(-log(q)) - // - // Then the result is given by: - // - // x(Y+R(x-B)) - // - // where Y is a constant, B is the lowest value of x for which - // the approximation is valid, and R(x-B) is optimized for a low - // absolute error compared to Y. - // - // Note that almost all code will really go through the first - // or maybe second approximation. After than we're dealing with very - // small input values indeed: 80 and 128 bit long double's go all the - // way down to ~ 1e-5000 so the "tail" is rather long... - double x = Math.Sqrt(-Math.Log(q)); - - if (x < 3) - { - // Max error found: 1.089051e-20 - const float y = 0.807220458984375f; - double xs = x - 1.125; - double r = evaluatePolynomial(xs, erv_inv_imp_cn) / evaluatePolynomial(xs, erv_inv_imp_cd); - result = (y * x) + (r * x); - } - else if (x < 6) - { - // Max error found: 8.389174e-21 - const float y = 0.93995571136474609375f; - double xs = x - 3; - double r = evaluatePolynomial(xs, erv_inv_imp_dn) / evaluatePolynomial(xs, erv_inv_imp_dd); - result = (y * x) + (r * x); - } - else if (x < 18) - { - // Max error found: 1.481312e-19 - const float y = 0.98362827301025390625f; - double xs = x - 6; - double r = evaluatePolynomial(xs, erv_inv_imp_en) / evaluatePolynomial(xs, erv_inv_imp_ed); - result = (y * x) + (r * x); - } - else if (x < 44) - { - // Max error found: 5.697761e-20 - const float y = 0.99714565277099609375f; - double xs = x - 18; - double r = evaluatePolynomial(xs, erv_inv_imp_fn) / evaluatePolynomial(xs, erv_inv_imp_fd); - result = (y * x) + (r * x); - } - else - { - // Max error found: 1.279746e-20 - const float y = 0.99941349029541015625f; - double xs = x - 44; - double r = evaluatePolynomial(xs, erv_inv_imp_gn) / evaluatePolynomial(xs, erv_inv_imp_gd); - result = (y * x) + (r * x); - } - } - - return s * result; - } - - /// - /// Evaluate a polynomial at point x. - /// Coefficients are ordered ascending by power with power k at index k. - /// Example: coefficients [3,-1,2] represent y=2x^2-x+3. - /// - /// The location where to evaluate the polynomial at. - /// The coefficients of the polynomial, coefficient for power k at index k. - /// - /// is a null reference. - /// - private static double evaluatePolynomial(double z, params double[] coefficients) - { - // 2020-10-07 jbialogrodzki #730 Since this is public API we should probably - // handle null arguments? It doesn't seem to have been done consistently in this class though. - ArgumentNullException.ThrowIfNull(coefficients); - - // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. - // Without this check, we attempted to peek coefficients at negative indices! - int n = coefficients.Length; - - if (n == 0) - { - return 0; - } - - double sum = coefficients[n - 1]; - - for (int i = n - 2; i >= 0; --i) - { - sum *= z; - sum += coefficients[i]; - } - - return sum; - } - } -} diff --git a/osu.Game/Utils/TagLibUtils.cs b/osu.Game/Utils/TagLibUtils.cs new file mode 100644 index 0000000000..4989d04238 --- /dev/null +++ b/osu.Game/Utils/TagLibUtils.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using TagLib; +using File = TagLib.File; + +namespace osu.Game.Utils +{ + public class TagLibUtils + { + /// + /// Creates a with culture-invariant MIME type detection, based on stream data. + /// + /// The created. + public static File GetTagLibFile(string filename, Stream stream) + { + var fileAbstraction = new StreamFileAbstraction(filename, stream); + + return File.Create(fileAbstraction, getMimeType(fileAbstraction.Name), ReadStyle.Average | ReadStyle.PictureLazy); + } + + /// + /// Creates a with culture-invariant MIME type detection based on a file on disk. + /// + /// The full path of the file to be created. + /// The created. + public static File GetTagLibFile(string filePath) => + File.Create(filePath, getMimeType(filePath), ReadStyle.Average | ReadStyle.PictureLazy); + + // Manual MIME type resolution to avoid culture variance (ie. https://github.com/ppy/osu/issues/32962) + private static string getMimeType(string fileName) => @"taglib/" + Path.GetExtension(fileName).TrimStart('.'); + + private class StreamFileAbstraction : File.IFileAbstraction + { + public StreamFileAbstraction(string filename, Stream fileStream) + { + ReadStream = fileStream; + Name = filename; + } + + public string Name { get; } + + public Stream ReadStream { get; } + public Stream WriteStream => ReadStream; + + public void CloseStream(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + stream.Close(); + } + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 847c209cc4..d4248b8570 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 62a65f291d..74dae877f1 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + diff --git a/osu.iOS/AppDelegate.cs b/osu.iOS/AppDelegate.cs new file mode 100644 index 0000000000..5d309f2fc1 --- /dev/null +++ b/osu.iOS/AppDelegate.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 Foundation; +using osu.Framework.iOS; +using UIKit; + +namespace osu.iOS +{ + [Register("AppDelegate")] + public class AppDelegate : GameApplicationDelegate + { + private UIInterfaceOrientationMask? defaultOrientationsMask; + private UIInterfaceOrientationMask? orientations; + + /// + /// The current orientation the game is displayed in. + /// + public UIInterfaceOrientation CurrentOrientation => Host.Window.UIWindow.WindowScene!.InterfaceOrientation; + + /// + /// Controls the orientations allowed for the device to rotate to, overriding the default allowed orientations. + /// + public UIInterfaceOrientationMask? Orientations + { + get => orientations; + set + { + if (orientations == value) + return; + + orientations = value; + + if (OperatingSystem.IsIOSVersionAtLeast(16)) + Host.Window.ViewController.SetNeedsUpdateOfSupportedInterfaceOrientations(); + else + UIViewController.AttemptRotationToDeviceOrientation(); + } + } + + protected override Framework.Game CreateGame() => new OsuGameIOS(this); + + public override UIInterfaceOrientationMask GetSupportedInterfaceOrientations(UIApplication application, UIWindow forWindow) + { + if (orientations != null) + return orientations.Value; + + if (defaultOrientationsMask == null) + { + defaultOrientationsMask = 0; + var defaultOrientations = (NSArray)NSBundle.MainBundle.ObjectForInfoDictionary("UISupportedInterfaceOrientations"); + + foreach (var value in defaultOrientations.ToArray()) + defaultOrientationsMask |= Enum.Parse(value.ToString().Replace("UIInterfaceOrientation", string.Empty)); + } + + return defaultOrientationsMask.Value; + } + } +} diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index ae36d00910..120e8caecc 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -34,9 +34,11 @@ CADisableMinimumFrameDurationOnPhone NSCameraUsageDescription - We don't really use the camera. + We don't use the camera. NSMicrophoneUsageDescription - We don't really use the microphone. + We don't use the microphone. + NSBluetoothAlwaysUsageDescription + We don't use Bluetooth. UISupportedInterfaceOrientations UIInterfaceOrientationLandscapeRight @@ -151,7 +153,15 @@ Editor + ITSAppUsesNonExemptEncryption + LSApplicationCategoryType public.app-category.music-games + LSSupportsOpeningDocumentsInPlace + + + GCSupportsGameMode + diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 2a4f9b87ac..fff781f38f 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -5,20 +5,75 @@ using System; using Foundation; using Microsoft.Maui.Devices; using osu.Framework.Graphics; +using osu.Framework.iOS; +using osu.Framework.Platform; using osu.Game; +using osu.Game.Screens; using osu.Game.Updater; using osu.Game.Utils; +using osuTK; +using UIKit; namespace osu.iOS { public partial class OsuGameIOS : OsuGame { + private readonly AppDelegate appDelegate; + public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); + public override string Version => NSBundle.MainBundle.InfoDictionary["OsuVersion"].ToString(); + + public override bool HideUnlicensedContent => true; + + public override Vector2 ScalingContainerTargetDrawSize => new Vector2(1024, 1024 * DrawHeight / DrawWidth); + + public OsuGameIOS(AppDelegate appDelegate) + { + this.appDelegate = appDelegate; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + UserPlayingState.BindValueChanged(_ => updateOrientation()); + } + + protected override void ScreenChanged(IOsuScreen? current, IOsuScreen? newScreen) + { + base.ScreenChanged(current, newScreen); + + if (newScreen != null) + updateOrientation(); + } + + private void updateOrientation() => UIApplication.SharedApplication.InvokeOnMainThread(() => + { + bool iPad = UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad; + var orientation = MobileUtils.GetOrientation(this, (IOsuScreen)ScreenStack.CurrentScreen, iPad); + + switch (orientation) + { + case MobileUtils.Orientation.Locked: + appDelegate.Orientations = (UIInterfaceOrientationMask)(1 << (int)appDelegate.CurrentOrientation); + break; + + case MobileUtils.Orientation.Portrait: + appDelegate.Orientations = UIInterfaceOrientationMask.Portrait; + break; + + case MobileUtils.Orientation.Default: + appDelegate.Orientations = null; + break; + } + }); + protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier(); protected override BatteryInfo CreateBatteryInfo() => new IOSBatteryInfo(); + protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorageIOS((IOSGameHost)host, defaultStorage); + protected override Edges SafeAreaOverrideEdges => // iOS shows a home indicator at the bottom, and adds a safe area to account for this. // Because we have the home indicator (mostly) hidden we don't really care about drawing in this region. diff --git a/osu.iOS/OsuStorageIOS.cs b/osu.iOS/OsuStorageIOS.cs new file mode 100644 index 0000000000..f3a5eec737 --- /dev/null +++ b/osu.iOS/OsuStorageIOS.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.IO; +using osu.Framework.iOS; +using osu.Framework.Platform; +using osu.Game.IO; + +namespace osu.iOS +{ + public class OsuStorageIOS : OsuStorage + { + private readonly IOSGameHost host; + + public OsuStorageIOS(IOSGameHost host, Storage defaultStorage) + : base(host, defaultStorage) + { + this.host = host; + } + + public override Storage GetExportStorage() => new IOSStorage(Path.GetTempPath(), host); + } +} diff --git a/osu.iOS/Application.cs b/osu.iOS/Program.cs similarity index 69% rename from osu.iOS/Application.cs rename to osu.iOS/Program.cs index 74bd58acb8..fd24ecf419 100644 --- a/osu.iOS/Application.cs +++ b/osu.iOS/Program.cs @@ -1,15 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.iOS; +using UIKit; namespace osu.iOS { - public static class Application + public static class Program { public static void Main(string[] args) { - GameApplication.Main(new OsuGameIOS()); + UIApplication.Main(args, null, typeof(AppDelegate)); } } } diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 19c0c610b5..3e8beddaa4 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -4,8 +4,12 @@ 13.4 Exe 0.1.0 - $(Version) - $(Version) + + + $([System.String]::Copy('$(Version)').Split('-')[0]) + + $(VersionNoSuffix) + $(VersionNoSuffix) @@ -18,4 +22,14 @@ + + + + $(AppBundleDir)/Info.plist + OsuVersion + + + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index ccd6db354b..99c42ec6f2 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -88,6 +88,7 @@ HINT DO_NOT_SHOW WARNING + HINT DO_NOT_SHOW WARNING WARNING @@ -170,7 +171,7 @@ WARNING HINT WARNING - WARNING + HINT WARNING ERROR WARNING @@ -303,7 +304,7 @@ 1 1 NEXT_LINE - MULTILINE + DO_NOT_CHANGE True True True @@ -780,6 +781,7 @@ See the LICENCE file in the repository root for full licence text. <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected, FileLocal" Description="Events"><ElementKinds><Kind Name="EVENT" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb" /></Policy> <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /></Policy> <Policy><Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /></Policy> <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Type parameters"><ElementKinds><Kind Name="TYPE_PARAMETER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> @@ -840,6 +842,7 @@ See the LICENCE file in the repository root for full licence text. True True True + True True True True