diff --git a/.editorconfig b/.editorconfig index c0ea55f4c8..67c47000d3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -191,6 +191,8 @@ csharp_style_prefer_index_operator = false:silent csharp_style_prefer_range_operator = false:silent csharp_style_prefer_switch_expression = false:none +csharp_style_namespace_declarations = block_scoped:warning + [*.{yaml,yml}] insert_final_newline = true indent_style = space diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c11f91994..a8167ec4db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,17 +13,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 # FIXME: Tools won't run in .NET 6.0 unless you install 3.1.x LTS side by side. # https://itnext.io/how-to-support-multiple-net-sdks-in-github-actions-workflows-b988daa884e - name: Install .NET 3.1.x LTS - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: "3.1.x" - name: Install .NET 6.0.x - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: "6.0.x" @@ -77,10 +77,10 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Install .NET 6.0.x - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: "6.0.x" @@ -94,7 +94,7 @@ jobs: # Attempt to upload results even if test fails. # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always - name: Upload Test Results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 if: ${{ always() }} with: name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} @@ -106,10 +106,10 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Install .NET 6.0.x - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: "6.0.x" @@ -121,14 +121,26 @@ jobs: build-only-ios: name: Build only (iOS) - runs-on: macos-latest + # `macos-13` is required, because Xcode 14.3 is required (see below). + # TODO: can be changed to `macos-latest` once `macos-13` becomes latest (currently in beta) + runs-on: macos-13 timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 + + # newest Microsoft.iOS.Sdk versions require Xcode 14.3. + # 14.3 is currently not the default Xcode version (https://github.com/actions/runner-images/blob/main/images/macos/macos-13-Readme.md#xcode), + # so set it manually. + # TODO: remove when 14.3 becomes the default Xcode version. + - name: Set Xcode version + shell: bash + run: | + sudo xcode-select -s "/Applications/Xcode_14.3.app" + echo "MD_APPLE_SDK_ROOT=/Applications/Xcode_14.3.app" >> $GITHUB_ENV - name: Install .NET 6.0.x - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: "6.0.x" diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index 9e11ab6663..2c6ec17e18 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -48,8 +48,8 @@ jobs: CONTINUE="no" fi - echo "::set-output name=continue::${CONTINUE}" - echo "::set-output name=matrix::${MATRIX_JSON}" + echo "continue=${CONTINUE}" >> $GITHUB_OUTPUT + echo "matrix=${MATRIX_JSON}" >> $GITHUB_OUTPUT diffcalc: name: Run runs-on: self-hosted @@ -80,34 +80,34 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo "::set-output name=branchname::$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" - echo "::set-output name=repo::$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" + echo "branchname=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" >> $GITHUB_OUTPUT + echo "repo=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" >> $GITHUB_OUTPUT # Checkout osu - name: Checkout osu (master) - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: 'master/osu' - name: Checkout osu (pr) - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: 'pr/osu' repository: ${{ steps.upstreambranch.outputs.repo }} ref: ${{ steps.upstreambranch.outputs.branchname }} - name: Checkout osu-difficulty-calculator (master) - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: ppy/osu-difficulty-calculator path: 'master/osu-difficulty-calculator' - name: Checkout osu-difficulty-calculator (pr) - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: ppy/osu-difficulty-calculator path: 'pr/osu-difficulty-calculator' - name: Install .NET 5.0.x - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: "5.0.x" diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml index cce3f23e5f..ff4165c414 100644 --- a/.github/workflows/sentry-release.yml +++ b/.github/workflows/sentry-release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 diff --git a/.github/workflows/update-web-mod-definitions.yml b/.github/workflows/update-web-mod-definitions.yml new file mode 100644 index 0000000000..32d3d37ffe --- /dev/null +++ b/.github/workflows/update-web-mod-definitions.yml @@ -0,0 +1,53 @@ +name: Update osu-web mod definitions +on: + push: + tags: + - '*' + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + update-mod-definitions: + name: Update osu-web mod definitions + runs-on: ubuntu-latest + steps: + - name: Install .NET 6.0.x + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "6.0.x" + + - name: Checkout ppy/osu + uses: actions/checkout@v3 + with: + path: osu + + - name: Checkout ppy/osu-tools + uses: actions/checkout@v3 + with: + repository: ppy/osu-tools + path: osu-tools + + - name: Checkout ppy/osu-web + uses: actions/checkout@v3 + with: + repository: ppy/osu-web + path: osu-web + + - name: Setup local game checkout for tools + run: ./UseLocalOsu.sh + working-directory: ./osu-tools + + - name: Regenerate mod definitions + run: dotnet run --project PerformanceCalculator -- mods > ../osu-web/database/mods.json + working-directory: ./osu-tools + + - name: Create pull request with changes + uses: peter-evans/create-pull-request@v5 + with: + title: Update mod definitions + body: "This PR has been auto-generated to update the mod definitions to match ppy/osu@${{ github.ref_name }}." + branch: update-mod-definitions + commit-message: Update mod definitions + path: osu-web + token: ${{ secrets.OSU_WEB_PULL_REQUEST_PAT }} diff --git a/README.md b/README.md index f3f025fa10..cf7ce35791 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,20 @@ The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Curre ## Status -This project is under heavy development, but is in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update. +This project is under constant development, but we aim to keep things in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update. -**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. +**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to a [stable release](https://osu.ppy.sh/home/download) of osu!. We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project: - Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). - You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). -- Read peppy's [blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where the project is currently and the roadmap going forward. ## Running osu! -If you are looking to install or test osu! without setting up a development environment, you can consume our [binary releases](https://github.com/ppy/osu/releases). Handy links below will download the latest version for your operating system of choice: +If you are looking to install or test osu! without setting up a development environment, you can consume our [releases](https://github.com/ppy/osu/releases). You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download). Failing that, you may use the links below to download the latest version for your operating system of choice: -**Latest build:** +**Latest release:** | [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.15+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 13.4+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | | ------------- | ------------- | ------------- | ------------- | ------------- | @@ -50,9 +49,8 @@ You can see some examples of custom rulesets by visiting the [custom ruleset dir Please make sure you have the following prerequisites: - A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed. -- When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). -- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). -- When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. + +When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). ### Downloading the source code @@ -89,7 +87,29 @@ _Due to a historical feature gap between .NET Core and Xamarin, running `dotnet` ### Testing with resource/framework modifications -Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be achieved by running some commands as documented on the [osu-resources](https://github.com/ppy/osu-resources/wiki/Testing-local-resources-checkout-with-other-projects) and [osu-framework](https://github.com/ppy/osu-framework/wiki/Testing-local-framework-checkout-with-other-projects) wiki pages. +Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands: + +Windows: + +```ps +UseLocalFramework.ps1 +UseLocalResources.ps1 +``` + +macOS / Linux: + +```ps +UseLocalFramework.sh +UseLocalResources.sh +``` + +Note that these commands assume you have the relevant project(s) checked out in adjacent directories: + +``` +|- osu // this repository +|- osu-framework +|- osu-resources +``` ### Code analysis @@ -105,7 +125,7 @@ When it comes to contributing to the project, the two main things you can do to If you wish to help with localisation efforts, head over to [crowdin](https://crowdin.com/project/osu-web). -For those interested, we love to reward quality contributions via [bounties](https://docs.google.com/spreadsheets/d/1jNXfj_S3Pb5PErA-czDdC9DUu4IgUbe1Lt8E7CYUJuE/view?&rm=minimal#gid=523803337), paid out via PayPal or osu!supporter tags. Don't hesitate to [request a bounty](https://docs.google.com/forms/d/e/1FAIpQLSet_8iFAgPMG526pBZ2Kic6HSh7XPM3fE8xPcnWNkMzINDdYg/viewform) for your work on this project. +We love to reward quality contributions. If you have made a large contribution, or are a regular contributor, you are welcome to [submit an expense via opencollective](https://opencollective.com/ppy/expenses/new). If you have any questions, feel free to [reach out to peppy](mailto:pe@ppy.sh) before doing so. ## Licence diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs index 29203f0a20..c5ada4288d 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osuTK; @@ -44,7 +43,7 @@ namespace osu.Game.Rulesets.Pippidon.Objects.Drawables public override IEnumerable GetSamples() => new[] { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; protected override void CheckForResult(bool userTriggered, double timeOffset) diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs index 554d03c79f..d198fa81cb 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Pippidon.UI; using osu.Game.Rulesets.Scoring; @@ -44,7 +43,7 @@ namespace osu.Game.Rulesets.Pippidon.Objects.Drawables public override IEnumerable GetSamples() => new[] { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; protected override void CheckForResult(bool userTriggered, double timeOffset) diff --git a/global.json b/global.json new file mode 100644 index 0000000000..5dcd5f425a --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "6.0.100", + "rollForward": "latestFeature" + } +} + diff --git a/osu.Android.props b/osu.Android.props index 4e580a6919..c88bea8265 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -11,7 +11,7 @@ manifestmerger.jar - + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index b3f6370ccb..d92fea27bf 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using System.Reflection; using System.Runtime.Versioning; using System.Threading.Tasks; @@ -139,7 +138,17 @@ namespace osu.Desktop desktopWindow.CursorState |= CursorState.Hidden; desktopWindow.Title = Name; - desktopWindow.DragDrop += f => fileDrop(new[] { f }); + desktopWindow.DragDrop += f => + { + // on macOS, URL associations are handled via SDL_DROPFILE events. + if (f.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal)) + { + HandleLink(f); + return; + } + + fileDrop(new[] { f }); + }; } protected override BatteryInfo CreateBatteryInfo() => new SDL2BatteryInfo(); @@ -151,10 +160,6 @@ namespace osu.Desktop { lock (importableFiles) { - string firstExtension = Path.GetExtension(filePaths.First()); - - if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return; - importableFiles.AddRange(filePaths); Logger.Log($"Adding {filePaths.Length} files for import"); diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 3d4db88471..941ab335e8 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -9,6 +9,7 @@ using osu.Framework.Logging; using osu.Game; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Screens.Play; using Squirrel; using Squirrel.SimpleSplat; using LogLevel = Squirrel.SimpleSplat.LogLevel; @@ -36,6 +37,9 @@ namespace osu.Desktop.Updater [Resolved] private OsuGameBase game { get; set; } = null!; + [Resolved] + private ILocalUserPlayInfo? localUserInfo { get; set; } + [BackgroundDependencyLoader] private void load(INotificationOverlay notifications) { @@ -55,6 +59,10 @@ namespace osu.Desktop.Updater try { + // Avoid any kind of update checking while gameplay is running. + if (localUserInfo?.IsPlaying.Value == true) + return false; + updateManager ??= new GithubUpdateManager(@"https://github.com/ppy/osu", false, github_token, @"osulazer"); var info = await updateManager.CheckForUpdate(!useDeltaPatching).ConfigureAwait(false); diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/AppDelegate.cs b/osu.Game.Rulesets.Catch.Tests.iOS/AppDelegate.cs deleted file mode 100644 index 64ff3f7151..0000000000 --- a/osu.Game.Rulesets.Catch.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using Foundation; -using osu.Framework.iOS; -using osu.Game.Tests; - -namespace osu.Game.Rulesets.Catch.Tests.iOS -{ - [Register("AppDelegate")] - public class AppDelegate : GameAppDelegate - { - protected override Framework.Game CreateGame() => new OsuTestBrowser(); - } -} diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs b/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs index 1fcb0aa427..d097c6a698 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Application.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 UIKit; +using osu.Framework.iOS; +using osu.Game.Tests; namespace osu.Game.Rulesets.Catch.Tests.iOS { @@ -11,7 +10,7 @@ namespace osu.Game.Rulesets.Catch.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, null, typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs index 18d3d29bdc..2426f8c886 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1])); AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0])); AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1])); - AddAssert("default slider velocity", () => lastObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); + AddAssert("default slider velocity", () => lastObject.SliderVelocityBindable.IsDefault); } [Test] @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor addPlacementSteps(times, positions); addPathCheckStep(times, positions); - AddAssert("slider velocity changed", () => !lastObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); + AddAssert("slider velocity changed", () => !lastObject.SliderVelocityBindable.IsDefault); } [Test] diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs index f25b66c360..beba5811fe 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs @@ -108,11 +108,11 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor double[] times = { 100, 300 }; float[] positions = { 200, 300 }; addBlueprintStep(times, positions); - AddAssert("default slider velocity", () => hitObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); + AddAssert("default slider velocity", () => hitObject.SliderVelocityBindable.IsDefault); addDragStartStep(times[1], positions[1]); AddMouseMoveStep(times[1], 400); - AddAssert("slider velocity changed", () => !hitObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); + AddAssert("slider velocity changed", () => !hitObject.SliderVelocityBindable.IsDefault); } [Test] diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs index 40dc7d2403..f21825668f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Tests NewCombo = i % 8 == 0, Samples = new List(new[] { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "normal", volume: 100) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }) }); } diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 7774a7da09..2c8ef9eae0 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -26,6 +26,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps var xPositionData = obj as IHasXPosition; var yPositionData = obj as IHasYPosition; var comboData = obj as IHasCombo; + var sliderVelocityData = obj as IHasSliderVelocity; switch (obj) { @@ -41,7 +42,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0, - LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y + LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y, + SliderVelocity = sliderVelocityData?.SliderVelocity ?? 1 }.Yield(); case IHasDuration endTime: diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs index 5f22ef5c12..1e63d32c41 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs @@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints private double placementStartTime; private double placementEndTime; + protected override bool IsValidForPlacement => HitObject.Duration > 0; + public BananaShowerPlacementBlueprint() { InternalChild = outline = new TimeSpanOutline(); @@ -49,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints case PlacementState.Active: if (e.Button != MouseButton.Right) break; - EndPlacement(HitObject.Duration > 0); + EndPlacement(true); return true; } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs index 74d6565600..7a577f8a83 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs @@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public void UpdateHitObjectFromPath(JuiceStream hitObject) { // The SV setting may need to be changed for the current path. - var svBindable = hitObject.DifficultyControlPoint.SliderVelocityBindable; + var svBindable = hitObject.SliderVelocityBindable; double svToVelocityFactor = hitObject.Velocity / svBindable.Value; double requiredVelocity = path.ComputeRequiredVelocity(); diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs index 03ec674abb..9e50b5a80f 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -24,6 +24,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints private InputManager inputManager = null!; + protected override bool IsValidForPlacement => HitObject.Duration > 0; + public JuiceStreamPlacementBlueprint() { InternalChildren = new Drawable[] @@ -70,7 +72,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints return true; case MouseButton.Right: - EndPlacement(HitObject.Duration > 0); + EndPlacement(true); return true; } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index ea5f54a775..8afeca3e51 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -48,7 +48,6 @@ namespace osu.Game.Rulesets.Catch.Edit private void load() { // todo: enable distance spacing once catch supports applying it to its existing distance snap grid implementation. - RightSideToolboxContainer.Alpha = 0; DistanceSpacingMultiplier.Disabled = true; LayerBelowRuleset.Add(new PlayfieldBorder @@ -133,7 +132,7 @@ namespace osu.Game.Rulesets.Catch.Edit result.ScreenSpacePosition.X = screenSpacePosition.X; - if (snapType.HasFlagFast(SnapType.Grids)) + if (snapType.HasFlagFast(SnapType.RelativeGrids)) { if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult && Vector2.Distance(snapResult.ScreenSpacePosition, result.ScreenSpacePosition) < distance_snap_radius) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.cs index cae19e9468..180cb98ed7 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDaycore.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModDaycore : ModDaycore { - public override double ScoreMultiplier => 0.3; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index e59a0a0431..6efb415880 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Catch.Mods public DifficultyBindable CircleSize { get; } = new DifficultyBindable { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, ExtendedMaxValue = 11, ReadCurrentFromDifficulty = diff => diff.CircleSize, @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Catch.Mods public DifficultyBindable ApproachRate { get; } = new DifficultyBindable { Precision = 0.1f, - MinValue = 1, + MinValue = 0, MaxValue = 10, ExtendedMaxValue = 11, ReadCurrentFromDifficulty = diff => diff.ApproachRate, diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs index 57c06e1cd1..83db9f665b 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDoubleTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModDoubleTime : ModDoubleTime { - public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHalfTime.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHalfTime.cs index ce06b841aa..3afb8c3d89 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHalfTime.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHalfTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModHalfTime : ModHalfTime { - public override double ScoreMultiplier => 0.3; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs index 9e38913be7..c537897439 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs @@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModNightcore : ModNightcore { - public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; } } diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index af03c9acab..b80527f379 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -22,11 +22,11 @@ namespace osu.Game.Rulesets.Catch.Objects public override Judgement CreateJudgement() => new CatchBananaJudgement(); - private static readonly List samples = new List { new BananaHitSampleInfo() }; + private static readonly IList default_banana_samples = new List { new BananaHitSampleInfo() }.AsReadOnly(); public Banana() { - Samples = samples; + Samples = default_banana_samples; } // override any external colour changes with banananana @@ -47,18 +47,18 @@ namespace osu.Game.Rulesets.Catch.Objects } } - private class BananaHitSampleInfo : HitSampleInfo, IEquatable + public class BananaHitSampleInfo : HitSampleInfo, IEquatable { private static readonly string[] lookup_names = { "Gameplay/metronomelow", "Gameplay/catch-banana" }; public override IEnumerable LookupNames => lookup_names; - public BananaHitSampleInfo(int volume = 0) + public BananaHitSampleInfo(int volume = 100) : base(string.Empty, volume: volume) { } - public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) => new BananaHitSampleInfo(newVolume.GetOr(Volume)); public bool Equals(BananaHitSampleInfo? other) diff --git a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs index b45f95a8e6..b05c8e5f77 100644 --- a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/BananaShower.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.Collections.Generic; using System.Threading; +using osu.Game.Audio; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; @@ -39,6 +41,7 @@ namespace osu.Game.Rulesets.Catch.Objects { StartTime = time, BananaIndex = i, + Samples = new List { new Banana.BananaHitSampleInfo(CreateHitSampleInfo().Volume) } }); time += spacing; diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 96e2d5c4e5..169e99c90c 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -16,7 +17,7 @@ using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Catch.Objects { - public class JuiceStream : CatchHitObject, IHasPathWithRepeats + public class JuiceStream : CatchHitObject, IHasPathWithRepeats, IHasSliderVelocity { /// /// Positional distance that results in a duration of one second, before any speed adjustments. @@ -27,6 +28,19 @@ namespace osu.Game.Rulesets.Catch.Objects public int RepeatCount { get; set; } + public BindableNumber SliderVelocityBindable { get; } = new BindableDouble(1) + { + Precision = 0.01, + MinValue = 0.1, + MaxValue = 10 + }; + + public double SliderVelocity + { + get => SliderVelocityBindable.Value; + set => SliderVelocityBindable.Value = value; + } + [JsonIgnore] private double velocityFactor; @@ -34,10 +48,10 @@ namespace osu.Game.Rulesets.Catch.Objects private double tickDistanceFactor; [JsonIgnore] - public double Velocity => velocityFactor * DifficultyControlPoint.SliderVelocity; + public double Velocity => velocityFactor * SliderVelocity; [JsonIgnore] - public double TickDistance => tickDistanceFactor * DifficultyControlPoint.SliderVelocity; + public double TickDistance => tickDistanceFactor * SliderVelocity; /// /// The length of one span of this . diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index b6a42407da..9323296b7f 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -1,17 +1,30 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Scoring { public partial class CatchScoreProcessor : ScoreProcessor { + private const int combo_cap = 200; + private const double combo_base = 4; + public CatchScoreProcessor() : base(new CatchRuleset()) { } - protected override double ClassicScoreMultiplier => 28; + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 600000 * comboProgress + + 400000 * Accuracy.Value * accuracyProgress + + bonusPortion; + } + + protected override double GetComboScoreChange(JudgementResult result) + => Judgement.ToNumericResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base)); } } diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 1c52c092ec..f77dab56c8 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -136,6 +136,7 @@ namespace osu.Game.Rulesets.Catch.UI Origin = Anchor.TopCentre; Size = new Vector2(BASE_SIZE); + if (difficulty != null) Scale = calculateScale(difficulty); @@ -333,8 +334,11 @@ namespace osu.Game.Rulesets.Catch.UI base.Update(); var scaleFromDirection = new Vector2((int)VisualDirection, 1); + body.Scale = scaleFromDirection; - caughtObjectContainer.Scale = hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One; + // Inverse of catcher scale is applied here, as catcher gets scaled by circle size and so do the incoming fruit. + caughtObjectContainer.Scale = (1 / Scale.X) * (flipCatcherPlate ? scaleFromDirection : Vector2.One); + hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One; // Correct overshooting. if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) || @@ -414,10 +418,13 @@ namespace osu.Game.Rulesets.Catch.UI private void clearPlate(DroppedObjectAnimation animation) { - var droppedObjects = caughtObjectContainer.Children.Select(getDroppedObject).ToArray(); + var caughtObjects = caughtObjectContainer.Children.ToArray(); caughtObjectContainer.Clear(false); + // Use the already returned PoolableDrawables for new objects + var droppedObjects = caughtObjects.Select(getDroppedObject).ToArray(); + droppedObjectTarget.AddRange(droppedObjects); foreach (var droppedObject in droppedObjects) @@ -426,10 +433,10 @@ namespace osu.Game.Rulesets.Catch.UI private void removeFromPlate(CaughtObject caughtObject, DroppedObjectAnimation animation) { - var droppedObject = getDroppedObject(caughtObject); - caughtObjectContainer.Remove(caughtObject, false); + var droppedObject = getDroppedObject(caughtObject); + droppedObjectTarget.Add(droppedObject); applyDropAnimation(droppedObject, animation); @@ -452,6 +459,8 @@ namespace osu.Game.Rulesets.Catch.UI break; } + // Define lifetime start for dropped objects to be disposed correctly when rewinding replay + d.LifetimeStart = Clock.CurrentTime; d.Expire(); } diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/AppDelegate.cs b/osu.Game.Rulesets.Mania.Tests.iOS/AppDelegate.cs deleted file mode 100644 index a528634f3b..0000000000 --- a/osu.Game.Rulesets.Mania.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using Foundation; -using osu.Framework.iOS; -using osu.Game.Tests; - -namespace osu.Game.Rulesets.Mania.Tests.iOS -{ - [Register("AppDelegate")] - public class AppDelegate : GameAppDelegate - { - protected override Framework.Game CreateGame() => new OsuTestBrowser(); - } -} diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs b/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs index a508198f7f..75a5a73058 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Application.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 UIKit; +using osu.Framework.iOS; +using osu.Game.Tests; namespace osu.Game.Rulesets.Mania.Tests.iOS { @@ -11,7 +10,7 @@ namespace osu.Game.Rulesets.Mania.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, null, typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 42e2099e3f..77db1b0bd8 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.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.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -40,7 +38,7 @@ namespace osu.Game.Rulesets.Mania.Tests private const double time_tail = 4000; private const double time_after_tail = 5250; - private List judgementResults; + private List judgementResults = new List(); /// /// -----[ ]----- @@ -61,6 +59,44 @@ namespace osu.Game.Rulesets.Mania.Tests assertNoteJudgement(HitResult.IgnoreMiss); } + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestCorrectInput() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Perfect); + assertNoteJudgement(HitResult.IgnoreHit); + } + + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestLateRelease() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Miss); + assertNoteJudgement(HitResult.IgnoreMiss); + } + /// /// -----[ ]----- /// x o @@ -521,9 +557,9 @@ namespace osu.Game.Rulesets.Mania.Tests private void assertLastTickJudgement(HitResult result) => AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type, () => Is.EqualTo(result)); - private ScoreAccessibleReplayPlayer currentPlayer; + private ScoreAccessibleReplayPlayer currentPlayer = null!; - private void performTest(List frames, Beatmap beatmap = null) + private void performTest(List frames, Beatmap? beatmap = null) { if (beatmap == null) { @@ -569,15 +605,13 @@ namespace osu.Game.Rulesets.Mania.Tests AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); - AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); } private partial class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; - public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; - protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs index 98046320cb..4e50fd924c 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs @@ -3,6 +3,9 @@ #nullable disable +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Rulesets.Mania.Configuration; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests @@ -10,5 +13,19 @@ namespace osu.Game.Rulesets.Mania.Tests public partial class TestSceneManiaPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("change direction to down", () => changeDirectionTo(ManiaScrollingDirection.Down)); + AddStep("change direction to up", () => changeDirectionTo(ManiaScrollingDirection.Up)); + } + + private void changeDirectionTo(ManiaScrollingDirection direction) + { + var rulesetConfig = (ManiaRulesetConfigManager)RulesetConfigs.GetConfigFor(new ManiaRuleset()).AsNonNull(); + rulesetConfig.SetValue(ManiaRulesetSetting.ScrollDirection, direction); + } } } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index 2bdd0e16ad..91b7be6e8f 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -14,7 +14,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Beatmaps.Formats; using osu.Game.Utils; namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy @@ -49,15 +48,14 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy Debug.Assert(distanceData != null); TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); - DifficultyControlPoint difficultyPoint = hitObject.DifficultyControlPoint; double beatLength; -#pragma warning disable 618 - if (difficultyPoint is LegacyBeatmapDecoder.LegacyDifficultyControlPoint legacyDifficultyPoint) -#pragma warning restore 618 - beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; + if (hitObject.LegacyBpmMultiplier.HasValue) + beatLength = timingPoint.BeatLength * hitObject.LegacyBpmMultiplier.Value; + else if (hitObject is IHasSliderVelocity hasSliderVelocity) + beatLength = timingPoint.BeatLength / hasSliderVelocity.SliderVelocity; else - beatLength = timingPoint.BeatLength / difficultyPoint.SliderVelocity; + beatLength = timingPoint.BeatLength; SpanCount = repeatsData?.SpanCount() ?? 1; StartTime = (int)Math.Round(hitObject.StartTime); diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 99a80ef28d..b2155968ea 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -21,18 +21,29 @@ namespace osu.Game.Rulesets.Mania.Configuration { base.InitialiseDefaults(); - SetDefault(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); + SetDefault(ManiaRulesetSetting.ScrollSpeed, 8, 1, 40); SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false); + +#pragma warning disable CS0618 + // Although obsolete, this is still required to populate the bindable from the database in case migration is required. + SetDefault(ManiaRulesetSetting.ScrollTime, null); + + if (Get(ManiaRulesetSetting.ScrollTime) is double scrollTime) + { + SetValue(ManiaRulesetSetting.ScrollSpeed, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)); + SetValue(ManiaRulesetSetting.ScrollTime, null); + } +#pragma warning restore CS0618 } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { - new TrackedSetting(ManiaRulesetSetting.ScrollTime, - scrollTime => new SettingDescription( - rawValue: scrollTime, + new TrackedSetting(ManiaRulesetSetting.ScrollSpeed, + speed => new SettingDescription( + rawValue: speed, name: RulesetSettingsStrings.ScrollSpeed, - value: RulesetSettingsStrings.ScrollSpeedTooltip(scrollTime, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)) + value: RulesetSettingsStrings.ScrollSpeedTooltip(DrawableManiaRuleset.ComputeScrollTime(speed), speed) ) ) }; @@ -40,7 +51,9 @@ namespace osu.Game.Rulesets.Mania.Configuration public enum ManiaRulesetSetting { + [Obsolete("Use ScrollSpeed instead.")] // Can be removed 2023-11-30 ScrollTime, + ScrollSpeed, ScrollDirection, TimingBasedNoteColouring } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 21beee0769..381af8be7f 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints [Resolved] private IScrollingInfo scrollingInfo { get; set; } + protected override bool IsValidForPlacement => HitObject.Duration > 0; + public HoldNotePlacementBlueprint() : base(new HoldNote()) { @@ -75,7 +77,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(HitObject.Duration > 0); + EndPlacement(true); } private double originalStartTime; diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs index 508733ad14..d5a9a311bc 100644 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup specialStyle = new LabelledSwitchButton { Label = "Use special (N+1) style", - Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 5k (4+1) or 8key (7+1) configurations.", + Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.", Current = { Value = Beatmap.BeatmapInfo.SpecialStyle } } }; diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index d324682989..e8fda3ec80 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -389,41 +389,23 @@ namespace osu.Game.Rulesets.Mania return base.GetDisplayNameForHitResult(result); } - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] { - new StatisticRow + new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }), + new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents) { - Columns = new[] - { - new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] { - Columns = new[] - { - new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] - { - new AverageHitError(score.HitEvents), - new UnstableRate(score.HitEvents) - }), true) - } - } + new AverageHitError(score.HitEvents), + new UnstableRate(score.HitEvents) + }), true) }; public override IRulesetFilterCriteria CreateRulesetFilterCriteria() diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index fc0b4a9ed9..a5434a36ab 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.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.Localisation; @@ -34,10 +33,10 @@ namespace osu.Game.Rulesets.Mania LabelText = RulesetSettingsStrings.ScrollingDirection, Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, - new SettingsSlider + new SettingsSlider { LabelText = RulesetSettingsStrings.ScrollSpeed, - Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), + Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed), KeyboardStep = 5 }, new SettingsCheckbox @@ -48,9 +47,9 @@ namespace osu.Game.Rulesets.Mania }; } - private partial class ManiaScrollSlider : RoundedSliderBar + private partial class ManiaScrollSlider : RoundedSliderBar { - public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip(Current.Value, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / Current.Value)); + public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip(DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value); } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs index bec0a6a1d3..309393b664 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDaycore.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModDaycore : ModDaycore { - public override double ScoreMultiplier => 0.5; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs index a302f95966..f4b9cf3b88 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDoubleTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModDoubleTime : ModDoubleTime { - public override double ScoreMultiplier => 1; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs index 014954dd60..8d48e3acde 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHalfTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModHalfTime : ModHalfTime { - public override double ScoreMultiplier => 0.5; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs index 4cc712060c..748725af9f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs @@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModNightcore : ModNightcore { - public override double ScoreMultiplier => 1; } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 25d0573a82..3f91328128 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -3,7 +3,6 @@ #nullable disable -using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -219,6 +218,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables if (Time.Current < releaseTime) releaseTime = null; + if (Time.Current < HoldStartTime) + endHold(); + // Pad the full size container so its contents (i.e. the masking container) reach under the tail. // This is required for the tail to not be masked away, since it lies outside the bounds of the hold note. sizingContainer.Padding = new MarginPadding @@ -236,17 +238,21 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }; // Position and resize the body to lie half-way under the head and the tail notes. + // The rationale for this is account for heads/tails with corner radius. bodyPiece.Y = (Direction.Value == ScrollingDirection.Up ? 1 : -1) * Head.Height / 2; bodyPiece.Height = DrawHeight - Head.Height / 2 + Tail.Height / 2; // As the note is being held, adjust the size of the sizing container. This has two effects: // 1. The contained masking container will mask the body and ticks. // 2. The head note will move along with the new "head position" in the container. - if (Head.IsHit && releaseTime == null && DrawHeight > 0) + // + // 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 && Time.Current >= HitObject.StartTime) { - // How far past the hit target this hold note is. Always a positive value. - float yOffset = Math.Max(0, Direction.Value == ScrollingDirection.Up ? -Y : Y); - sizingContainer.Height = Math.Clamp(1 - yOffset / DrawHeight, 0, 1); + // How far past the hit target this hold note is. + float yOffset = Direction.Value == ScrollingDirection.Up ? -Y : Y; + sizingContainer.Height = 1 - yOffset / DrawHeight; } } @@ -321,14 +327,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables if (e.Action != Action.Value) return; - // do not run any of this logic when rewinding, as it inverts order of presses/releases. - if (Time.Elapsed < 0) - return; - // Make sure a hold was started if (HoldStartTime == null) return; + // do not run any of this logic when rewinding, as it inverts order of presses/releases. + if (Time.Elapsed < 0) + return; + Tail.UpdateResult(); endHold(); @@ -349,13 +355,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. - if (HitObject.SampleControlPoint == null) - { - throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}." - + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); - } - - slidingSample.Samples = HitObject.CreateSlidingSamples().Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + slidingSample.Samples = HitObject.CreateSlidingSamples().Cast().ToArray(); } public override void StopAllSamples() diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index 20ea962994..e7326df07d 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail; - protected DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject; + protected internal DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject; public DrawableHoldNoteTail() : this(null) diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json index 6f1d45ad8c..4d298bb671 100644 --- a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json @@ -10,7 +10,7 @@ ["Gameplay/soft-hitnormal"], ["Gameplay/drum-hitnormal"] ], - "Samples": ["Gameplay/-hitnormal"] + "Samples": ["Gameplay/normal-hitnormal"] }, { "StartTime": 1875.0, "EndTime": 2750.0, @@ -19,7 +19,7 @@ ["Gameplay/soft-hitnormal"], ["Gameplay/drum-hitnormal"] ], - "Samples": ["Gameplay/-hitnormal"] + "Samples": ["Gameplay/normal-hitnormal"] }] }, { "StartTime": 3750.0, diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index f724972a29..3341f834dd 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -1,23 +1,29 @@ // 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.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { - internal partial class ManiaScoreProcessor : ScoreProcessor + public partial class ManiaScoreProcessor : ScoreProcessor { + private const double combo_base = 4; + public ManiaScoreProcessor() : base(new ManiaRuleset()) { } - protected override double DefaultAccuracyPortion => 0.99; + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 200000 * comboProgress + + 800000 * Math.Pow(Accuracy.Value, 2 + 2 * Accuracy.Value) * accuracyProgress + + bonusPortion; + } - protected override double DefaultComboPortion => 0.01; - - protected override double ClassicScoreMultiplier => 16; + protected override double GetComboScoreChange(JudgementResult result) + => Judgement.ToNumericResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(400, combo_base)); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs index e32de6f3f3..d490d3f944 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs @@ -43,9 +43,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon { largeFaint = new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, + Height = ArgonNotePiece.NOTE_ACCENT_RATIO, Masking = true, CornerRadius = ArgonNotePiece.CORNER_RADIUS, Blending = BlendingParameters.Additive, @@ -80,11 +79,15 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon if (direction.NewValue == ScrollingDirection.Up) { Anchor = Anchor.TopCentre; + largeFaint.Anchor = Anchor.TopCentre; + largeFaint.Origin = Anchor.TopCentre; Y = ArgonNotePiece.NOTE_HEIGHT / 2; } else { Anchor = Anchor.BottomCentre; + largeFaint.Anchor = Anchor.BottomCentre; + largeFaint.Origin = Anchor.BottomCentre; Y = -ArgonNotePiece.NOTE_HEIGHT / 2; } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs index 4ffb4a435b..cf5931231c 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon private void load(IScrollingInfo scrollingInfo) { RelativeSizeAxes = Axes.X; - Height = ArgonNotePiece.NOTE_HEIGHT; + Height = ArgonNotePiece.NOTE_HEIGHT * ArgonNotePiece.NOTE_ACCENT_RATIO; Masking = true; CornerRadius = ArgonNotePiece.CORNER_RADIUS; diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs index 1f52f5f15f..57fa1c10ae 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs @@ -20,10 +20,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon public partial class ArgonHoldBodyPiece : CompositeDrawable, IHoldNoteBody { protected readonly Bindable AccentColour = new Bindable(); - protected readonly IBindable IsHitting = new Bindable(); private Drawable background = null!; - private Box foreground = null!; + private ArgonHoldNoteHittingLayer hittingLayer = null!; public ArgonHoldBodyPiece() { @@ -32,7 +31,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon // Without this, the width of the body will be slightly larger than the head/tail. Masking = true; CornerRadius = ArgonNotePiece.CORNER_RADIUS; - Blending = BlendingParameters.Additive; } [BackgroundDependencyLoader(true)] @@ -41,12 +39,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon InternalChildren = new[] { background = new Box { RelativeSizeAxes = Axes.Both }, - foreground = new Box - { - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - Alpha = 0, - }, + hittingLayer = new ArgonHoldNoteHittingLayer() }; if (drawableObject != null) @@ -54,44 +47,19 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon var holdNote = (DrawableHoldNote)drawableObject; AccentColour.BindTo(holdNote.AccentColour); - IsHitting.BindTo(holdNote.IsHitting); + hittingLayer.AccentColour.BindTo(holdNote.AccentColour); + ((IBindable)hittingLayer.IsHitting).BindTo(holdNote.IsHitting); } AccentColour.BindValueChanged(colour => { - background.Colour = colour.NewValue.Darken(1.2f); - foreground.Colour = colour.NewValue.Opacity(0.2f); + background.Colour = colour.NewValue.Darken(0.6f); }, true); - - IsHitting.BindValueChanged(hitting => - { - const float animation_length = 50; - - foreground.ClearTransforms(); - - if (hitting.NewValue) - { - // wait for the next sync point - double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); - - using (foreground.BeginDelayedSequence(synchronisedOffset)) - { - foreground.FadeTo(1, animation_length).Then() - .FadeTo(0.5f, animation_length) - .Loop(); - } - } - else - { - foreground.FadeOut(animation_length); - } - }); } public void Recycle() { - foreground.ClearTransforms(); - foreground.Alpha = 0; + hittingLayer.Recycle(); } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteHeadPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteHeadPiece.cs new file mode 100644 index 0000000000..b9cc73c75c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteHeadPiece.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.Framework.Graphics.Shapes; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + internal partial class ArgonHoldNoteHeadPiece : ArgonNotePiece + { + protected override Drawable CreateIcon() => new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 2, + Size = new Vector2(20, 5), + }; + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteHittingLayer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteHittingLayer.cs new file mode 100644 index 0000000000..9e7afa8b9e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteHittingLayer.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osuTK.Graphics; +using Box = osu.Framework.Graphics.Shapes.Box; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public partial class ArgonHoldNoteHittingLayer : Box + { + public readonly Bindable AccentColour = new Bindable(); + public readonly Bindable IsHitting = new Bindable(); + + public ArgonHoldNoteHittingLayer() + { + RelativeSizeAxes = Axes.Both; + Blending = BlendingParameters.Additive; + Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AccentColour.BindValueChanged(colour => + { + Colour = colour.NewValue.Lighten(0.2f).Opacity(0.3f); + }, true); + + IsHitting.BindValueChanged(hitting => + { + const float animation_length = 80; + + ClearTransforms(); + + if (hitting.NewValue) + { + // wait for the next sync point + double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); + + using (BeginDelayedSequence(synchronisedOffset)) + { + this.FadeTo(1, animation_length, Easing.OutSine).Then() + .FadeTo(0.5f, animation_length, Easing.InSine) + .Loop(); + } + } + else + { + this.FadeOut(animation_length); + } + }, true); + } + + public void Recycle() + { + ClearTransforms(); + Alpha = 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs index 428439d52c..efd7f4f280 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs @@ -5,8 +5,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osuTK; @@ -16,47 +18,68 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon { internal partial class ArgonHoldNoteTailPiece : CompositeDrawable { + [Resolved] + private DrawableHitObject? drawableObject { get; set; } + private readonly IBindable direction = new Bindable(); private readonly IBindable accentColour = new Bindable(); - private readonly Box shadeBackground; - private readonly Box shadeForeground; + private readonly Box foreground; + private readonly ArgonHoldNoteHittingLayer hittingLayer; + private readonly Box foregroundAdditive; public ArgonHoldNoteTailPiece() { RelativeSizeAxes = Axes.X; Height = ArgonNotePiece.NOTE_HEIGHT; - CornerRadius = ArgonNotePiece.CORNER_RADIUS; - Masking = true; - InternalChildren = new Drawable[] { - shadeBackground = new Box - { - RelativeSizeAxes = Axes.Both, - }, new Container { - RelativeSizeAxes = Axes.Both, - Height = ArgonNotePiece.NOTE_ACCENT_RATIO, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = ArgonNotePiece.NOTE_HEIGHT, CornerRadius = ArgonNotePiece.CORNER_RADIUS, Masking = true, Children = new Drawable[] { - shadeForeground = new Box + new Box { RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Colour4.Black), + // Avoid ugly single pixel overlap. + Height = 0.9f, }, - }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = ArgonNotePiece.NOTE_ACCENT_RATIO, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] + { + foreground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + hittingLayer = new ArgonHoldNoteHittingLayer(), + foregroundAdditive = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Height = 0.5f, + }, + }, + }, + } }, }; } [BackgroundDependencyLoader(true)] - private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) + private void load(IScrollingInfo scrollingInfo) { direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); @@ -65,9 +88,24 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon { accentColour.BindTo(drawableObject.AccentColour); accentColour.BindValueChanged(onAccentChanged, true); + + drawableObject.HitObjectApplied += hitObjectApplied; } } + private void hitObjectApplied(DrawableHitObject drawableHitObject) + { + var holdNoteTail = (DrawableHoldNoteTail)drawableHitObject; + + hittingLayer.Recycle(); + + hittingLayer.AccentColour.UnbindBindings(); + hittingLayer.AccentColour.BindTo(holdNoteTail.HoldNote.AccentColour); + + hittingLayer.IsHitting.UnbindBindings(); + ((IBindable)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHitting); + } + private void onDirectionChanged(ValueChangedEvent direction) { Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Up ? -1 : 1); @@ -75,8 +113,20 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon private void onAccentChanged(ValueChangedEvent accent) { - shadeBackground.Colour = accent.NewValue.Darken(1.7f); - shadeForeground.Colour = accent.NewValue.Darken(1.1f); + foreground.Colour = accent.NewValue.Darken(0.6f); // matches body + + foregroundAdditive.Colour = ColourInfo.GradientVertical( + accent.NewValue.Opacity(0.4f), + accent.NewValue.Opacity(0) + ); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.HitObjectApplied -= hitObjectApplied; } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs index 2a5bce255c..3a519283f1 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs @@ -26,7 +26,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon private readonly IBindable accentColour = new Bindable(); private readonly Box colouredBox; - private readonly Box shadow; public ArgonNotePiece() { @@ -36,11 +35,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon CornerRadius = CORNER_RADIUS; Masking = true; - InternalChildren = new Drawable[] + InternalChildren = new[] { - shadow = new Box + new Box { RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Colour4.Black) }, new Container { @@ -65,18 +65,22 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon RelativeSizeAxes = Axes.X, Height = CORNER_RADIUS * 2, }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Y = 4, - Icon = FontAwesome.Solid.AngleDown, - Size = new Vector2(20), - Scale = new Vector2(1, 0.7f) - } + CreateIcon(), }; } + protected virtual Drawable CreateIcon() => new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 4, + // TODO: replace with a non-squashed version. + // The 0.7f height scale should be removed. + Icon = FontAwesome.Solid.AngleDown, + Size = new Vector2(20), + Scale = new Vector2(1, 0.7f) + }; + [BackgroundDependencyLoader(true)] private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) { @@ -105,8 +109,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon accent.NewValue.Lighten(0.1f), accent.NewValue ); - - shadow.Colour = accent.NewValue.Darken(0.5f); } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index 057b7eb0d9..007d02400a 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -50,6 +50,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon return new ArgonHoldNoteTailPiece(); case ManiaSkinComponents.HoldNoteHead: + return new ArgonHoldNoteHeadPiece(); + case ManiaSkinComponents.Note: return new ArgonNotePiece(); @@ -69,12 +71,23 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon return base.GetDrawableComponent(lookup); } + private static readonly Color4 colour_special_column = new Color4(169, 106, 255, 255); + + private const int total_colours = 6; + + private static readonly Color4 colour_yellow = new Color4(255, 197, 40, 255); + private static readonly Color4 colour_orange = new Color4(252, 109, 1, 255); + private static readonly Color4 colour_pink = new Color4(213, 35, 90, 255); + private static readonly Color4 colour_purple = new Color4(203, 60, 236, 255); + private static readonly Color4 colour_cyan = new Color4(72, 198, 255, 255); + private static readonly Color4 colour_green = new Color4(100, 192, 92, 255); + public override IBindable? GetConfig(TLookup lookup) { if (lookup is ManiaSkinConfigurationLookup maniaLookup) { - int column = maniaLookup.ColumnIndex ?? 0; - var stage = beatmap.GetStageForColumnIndex(column); + int columnIndex = maniaLookup.ColumnIndex ?? 0; + var stage = beatmap.GetStageForColumnIndex(columnIndex); switch (maniaLookup.Lookup) { @@ -87,53 +100,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case LegacyManiaSkinConfigurationLookups.ColumnWidth: return SkinUtils.As(new Bindable( - stage.IsSpecialColumn(column) ? 120 : 60 + stage.IsSpecialColumn(columnIndex) ? 120 : 60 )); case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: - Color4 colour; - - const int total_colours = 7; - - if (stage.IsSpecialColumn(column)) - colour = new Color4(159, 101, 255, 255); - else - { - switch (column % total_colours) - { - case 0: - colour = new Color4(240, 216, 0, 255); - break; - - case 1: - colour = new Color4(240, 101, 0, 255); - break; - - case 2: - colour = new Color4(240, 0, 130, 255); - break; - - case 3: - colour = new Color4(192, 0, 240, 255); - break; - - case 4: - colour = new Color4(0, 96, 240, 255); - break; - - case 5: - colour = new Color4(0, 226, 240, 255); - break; - - case 6: - colour = new Color4(0, 240, 96, 255); - break; - - default: - throw new ArgumentOutOfRangeException(); - } - } + var colour = getColourForLayout(columnIndex, stage); return SkinUtils.As(new Bindable(colour)); } @@ -141,5 +113,203 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon return base.GetConfig(lookup); } + + private Color4 getColourForLayout(int columnIndex, StageDefinition stage) + { + // Account for cases like dual-stage (assume that all stages have the same column count for now). + columnIndex %= stage.Columns; + + // For now, these are defined per column count as per https://user-images.githubusercontent.com/50823728/218038463-b450f46c-ef21-4551-b133-f866be59970c.png + // See https://github.com/ppy/osu/discussions/21996 for discussion. + switch (stage.Columns) + { + case 1: + return colour_yellow; + + case 2: + switch (columnIndex) + { + case 0: return colour_green; + + case 1: return colour_cyan; + + default: throw new ArgumentOutOfRangeException(); + } + + case 3: + switch (columnIndex) + { + case 0: return colour_pink; + + case 1: return colour_orange; + + case 2: return colour_yellow; + + default: throw new ArgumentOutOfRangeException(); + } + + case 4: + switch (columnIndex) + { + case 0: return colour_yellow; + + case 1: return colour_orange; + + case 2: return colour_pink; + + case 3: return colour_purple; + + default: throw new ArgumentOutOfRangeException(); + } + + case 5: + switch (columnIndex) + { + case 0: return colour_pink; + + case 1: return colour_orange; + + case 2: return colour_yellow; + + case 3: return colour_green; + + case 4: return colour_cyan; + + default: throw new ArgumentOutOfRangeException(); + } + + case 6: + switch (columnIndex) + { + case 0: return colour_pink; + + case 1: return colour_orange; + + case 2: return colour_yellow; + + case 3: return colour_cyan; + + case 4: return colour_purple; + + case 5: return colour_pink; + + default: throw new ArgumentOutOfRangeException(); + } + + case 7: + switch (columnIndex) + { + case 0: return colour_pink; + + case 1: return colour_cyan; + + case 2: return colour_pink; + + case 3: return colour_special_column; + + case 4: return colour_green; + + case 5: return colour_cyan; + + case 6: return colour_green; + + default: throw new ArgumentOutOfRangeException(); + } + + case 8: + switch (columnIndex) + { + case 0: return colour_purple; + + case 1: return colour_pink; + + case 2: return colour_orange; + + case 3: return colour_yellow; + + case 4: return colour_yellow; + + case 5: return colour_orange; + + case 6: return colour_pink; + + case 7: return colour_purple; + + default: throw new ArgumentOutOfRangeException(); + } + + case 9: + switch (columnIndex) + { + case 0: return colour_purple; + + case 1: return colour_pink; + + case 2: return colour_orange; + + case 3: return colour_yellow; + + case 4: return colour_special_column; + + case 5: return colour_yellow; + + case 6: return colour_orange; + + case 7: return colour_pink; + + case 8: return colour_purple; + + default: throw new ArgumentOutOfRangeException(); + } + + case 10: + switch (columnIndex) + { + case 0: return colour_purple; + + case 1: return colour_pink; + + case 2: return colour_orange; + + case 3: return colour_yellow; + + case 4: return colour_cyan; + + case 5: return colour_green; + + case 6: return colour_yellow; + + case 7: return colour_orange; + + case 8: return colour_pink; + + case 9: return colour_purple; + + default: throw new ArgumentOutOfRangeException(); + } + } + + // fallback for unhandled scenarios + + if (stage.IsSpecialColumn(columnIndex)) + return colour_special_column; + + switch (columnIndex % total_colours) + { + case 0: return colour_yellow; + + case 1: return colour_orange; + + case 2: return colour_pink; + + case 3: return colour_purple; + + case 4: return colour_cyan; + + case 5: return colour_green; + + default: throw new ArgumentOutOfRangeException(); + } + } } } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index af8758fb5e..2d373c0471 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -33,12 +33,12 @@ namespace osu.Game.Rulesets.Mania.UI public partial class DrawableManiaRuleset : DrawableScrollingRuleset { /// - /// The minimum time range. This occurs at a of 40. + /// The minimum time range. This occurs at a of 40. /// public const double MIN_TIME_RANGE = 290; /// - /// The maximum time range. This occurs at a of 1. + /// The maximum time range. This occurs with a of 1. /// public const double MAX_TIME_RANGE = 11485; @@ -69,7 +69,8 @@ namespace osu.Game.Rulesets.Mania.UI protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod; private readonly Bindable configDirection = new Bindable(); - private readonly BindableDouble configTimeRange = new BindableDouble(); + private readonly BindableInt configScrollSpeed = new BindableInt(); + private double smoothTimeRange; // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); @@ -78,6 +79,9 @@ namespace osu.Game.Rulesets.Mania.UI : base(ruleset, beatmap, mods) { BarLines = new BarLineGenerator(Beatmap).BarLines; + + TimeRange.MinValue = 1; + TimeRange.MaxValue = MAX_TIME_RANGE; } [BackgroundDependencyLoader] @@ -104,30 +108,28 @@ namespace osu.Game.Rulesets.Mania.UI Config.BindWith(ManiaRulesetSetting.ScrollDirection, configDirection); configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); - Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange); - TimeRange.MinValue = configTimeRange.MinValue; - TimeRange.MaxValue = configTimeRange.MaxValue; + Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed); + configScrollSpeed.BindValueChanged(speed => this.TransformTo(nameof(smoothTimeRange), ComputeScrollTime(speed.NewValue), 200, Easing.OutQuint)); + + TimeRange.Value = smoothTimeRange = ComputeScrollTime(configScrollSpeed.Value); } - protected override void AdjustScrollSpeed(int amount) - { - this.TransformTo(nameof(relativeTimeRange), relativeTimeRange + amount, 200, Easing.OutQuint); - } - - private double relativeTimeRange - { - get => MAX_TIME_RANGE / configTimeRange.Value; - set => configTimeRange.Value = MAX_TIME_RANGE / value; - } + protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount; protected override void Update() { base.Update(); - updateTimeRange(); } - private void updateTimeRange() => TimeRange.Value = configTimeRange.Value * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value; + private void updateTimeRange() => TimeRange.Value = smoothTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value; + + /// + /// Computes a scroll time (in milliseconds) from a scroll speed in the range of 1-40. + /// + /// The scroll speed. + /// The scroll time. + public static double ComputeScrollTime(int scrollSpeed) => MAX_TIME_RANGE / scrollSpeed; public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(); diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/AppDelegate.cs b/osu.Game.Rulesets.Osu.Tests.iOS/AppDelegate.cs deleted file mode 100644 index fa40a8536e..0000000000 --- a/osu.Game.Rulesets.Osu.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using Foundation; -using osu.Framework.iOS; -using osu.Game.Tests; - -namespace osu.Game.Rulesets.Osu.Tests.iOS -{ - [Register("AppDelegate")] - public class AppDelegate : GameAppDelegate - { - 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/Application.cs index 6ef29fa68e..f9059014a5 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Application.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 UIKit; +using osu.Framework.iOS; +using osu.Game.Tests; namespace osu.Game.Rulesets.Osu.Tests.iOS { @@ -11,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, null, typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs index e7ac38c20e..b05c755bfd 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs @@ -138,8 +138,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); return slider1 is not null && mergedSlider.HeadCircle.Samples.SequenceEqual(slider1.HeadCircle.Samples) && mergedSlider.TailCircle.Samples.SequenceEqual(slider1.TailCircle.Samples) - && mergedSlider.Samples.SequenceEqual(slider1.Samples) - && mergedSlider.SampleControlPoint.IsRedundant(slider1.SampleControlPoint); + && mergedSlider.Samples.SequenceEqual(slider1.Samples); }); AddAssert("slider end is at same completion for last slider", () => diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs index 6cb77c7b92..605771fb20 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -181,10 +181,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { if (slider is null) return; - slider.SampleControlPoint.SampleBank = "soft"; - slider.SampleControlPoint.SampleVolume = 70; - sample = new HitSampleInfo("hitwhistle"); - slider.Samples.Add(sample); + sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70); + slider.Samples.Add(sample.With()); }); AddStep("select added slider", () => @@ -207,9 +205,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("sliders have hitsounds", hasHitsounds); bool hasHitsounds() => sample is not null && - EditorBeatmap.HitObjects.All(o => o.SampleControlPoint.SampleBank == "soft" && - o.SampleControlPoint.SampleVolume == 70 && - o.Samples.Contains(sample)); + EditorBeatmap.HitObjects.All(o => o.Samples.Contains(sample)); } private bool sliderCreatedFor(Slider s, double startTime, double endTime, params (Vector2 pos, PathType? pathType)[] expectedControlPoints) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs index 53465d43c9..a162d9a491 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs @@ -199,8 +199,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor Precision.AlmostEquals(circle.StartTime, time, 1) && Precision.AlmostEquals(circle.Position, position, 0.01f) && circle.NewCombo == startsNewCombo - && circle.Samples.SequenceEqual(slider.HeadCircle.Samples) - && circle.SampleControlPoint.IsRedundant(slider.SampleControlPoint); + && circle.Samples.SequenceEqual(slider.HeadCircle.Samples); } private bool sliderRestored(Slider slider) diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 8fdab9f1f9..616a9c362d 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -17,6 +17,18 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { public partial class TestSceneOsuModAutoplay : OsuModTestScene { + [Test] + public void TestCursorPositionStoredToJudgement() + { + CreateModTest(new ModTestData + { + Autoplay = true, + PassCondition = () => + Player.ScoreProcessor.JudgedHits >= 1 + && Player.ScoreProcessor.HitEvents.Any(e => e.Position != null) + }); + } + [Test] public void TestSpmUnaffectedByRateAdjust() => runSpmTest(new OsuModDaycore diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModBubbles.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModBubbles.cs new file mode 100644 index 0000000000..e72a1f79f5 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModBubbles.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 NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModBubbles : OsuModTestScene + { + [Test] + public void TestOsuModBubbles() => CreateModTest(new ModTestData + { + Mod = new OsuModBubbles(), + Autoplay = true, + PassCondition = () => true + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/cursor-ripple@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/cursor-ripple@2x.png new file mode 100644 index 0000000000..258162c486 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/cursor-ripple@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 907422858e..c84a6ab70f 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -18,6 +19,7 @@ using osu.Framework.Testing.Input; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; +using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Screens.Play; @@ -40,6 +42,8 @@ namespace osu.Game.Rulesets.Osu.Tests private Drawable background; + private readonly Bindable ripples = new Bindable(); + public TestSceneGameplayCursor() { var ruleset = new OsuRuleset(); @@ -57,6 +61,8 @@ namespace osu.Game.Rulesets.Osu.Tests }); }); + AddToggleStep("ripples", v => ripples.Value = v); + AddSliderStep("circle size", 0f, 10f, 0f, val => { config.SetValue(OsuSetting.AutoCursorSize, true); @@ -67,6 +73,13 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("test cursor container", () => loadContent(false)); } + [BackgroundDependencyLoader] + private void load() + { + var rulesetConfig = (OsuRulesetConfigManager)RulesetConfigs.GetConfigFor(Ruleset.Value.CreateInstance()).AsNonNull(); + rulesetConfig.BindWith(OsuRulesetSetting.ShowCursorRipples, ripples); + } + [TestCase(1, 1)] [TestCase(5, 1)] [TestCase(10, 1)] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs index 5d9316a21b..ee70441688 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs @@ -439,7 +439,7 @@ namespace osu.Game.Rulesets.Osu.Tests { public TestSlider() { - DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = 0.1f }; + SliderVelocity = 0.1f; DefaultsApplied += _ => { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs index 72bcec6045..bb424eb587 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs @@ -21,7 +21,7 @@ using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.UI.Cursor; -using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Visual; using osuTK; using osuTK.Graphics; @@ -34,9 +34,9 @@ namespace osu.Game.Rulesets.Osu.Tests [Resolved] private OsuConfigManager config { get; set; } = null!; - private TestActionKeyCounter leftKeyCounter = null!; + private DefaultKeyCounter leftKeyCounter = null!; - private TestActionKeyCounter rightKeyCounter = null!; + private DefaultKeyCounter rightKeyCounter = null!; private OsuInputManager osuInputManager = null!; @@ -59,14 +59,14 @@ namespace osu.Game.Rulesets.Osu.Tests Origin = Anchor.Centre, Children = new Drawable[] { - leftKeyCounter = new TestActionKeyCounter(OsuAction.LeftButton) + leftKeyCounter = new DefaultKeyCounter(new TestActionKeyCounterTrigger(OsuAction.LeftButton)) { Anchor = Anchor.Centre, Origin = Anchor.CentreRight, Depth = float.MinValue, X = -100, }, - rightKeyCounter = new TestActionKeyCounter(OsuAction.RightButton) + rightKeyCounter = new DefaultKeyCounter(new TestActionKeyCounterTrigger(OsuAction.RightButton)) { Anchor = Anchor.Centre, Origin = Anchor.CentreLeft, @@ -150,6 +150,42 @@ namespace osu.Game.Rulesets.Osu.Tests assertKeyCounter(1, 1); } + [Test] + public void TestPositionalTrackingAfterLongDistanceTravelled() + { + // When a single touch has already travelled enough distance on screen, it should remain as the positional + // tracking touch until released (unless a direct touch occurs). + + beginTouch(TouchSource.Touch1); + + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + // cover some distance + beginTouch(TouchSource.Touch1, new Vector2(0)); + beginTouch(TouchSource.Touch1, new Vector2(9999)); + beginTouch(TouchSource.Touch1, new Vector2(0)); + beginTouch(TouchSource.Touch1, new Vector2(9999)); + beginTouch(TouchSource.Touch1); + + beginTouch(TouchSource.Touch2); + + assertKeyCounter(1, 1); + checkNotPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + // in this case, touch 2 should not become the positional tracking touch. + checkPosition(TouchSource.Touch1); + + // even if the second touch moves on the screen, the original tracking touch is retained. + beginTouch(TouchSource.Touch2, new Vector2(0)); + beginTouch(TouchSource.Touch2, new Vector2(9999)); + beginTouch(TouchSource.Touch2, new Vector2(0)); + beginTouch(TouchSource.Touch2, new Vector2(9999)); + + checkPosition(TouchSource.Touch1); + } + [Test] public void TestPositionalInputUpdatesOnlyFromMostRecentTouch() { @@ -562,8 +598,8 @@ namespace osu.Game.Rulesets.Osu.Tests private void assertKeyCounter(int left, int right) { - AddAssert($"The left key was pressed {left} times", () => leftKeyCounter.CountPresses, () => Is.EqualTo(left)); - AddAssert($"The right key was pressed {right} times", () => rightKeyCounter.CountPresses, () => Is.EqualTo(right)); + AddAssert($"The left key was pressed {left} times", () => leftKeyCounter.CountPresses.Value, () => Is.EqualTo(left)); + AddAssert($"The right key was pressed {right} times", () => rightKeyCounter.CountPresses.Value, () => Is.EqualTo(right)); } private void releaseAllTouches() @@ -579,11 +615,11 @@ namespace osu.Game.Rulesets.Osu.Tests private void checkNotPressed(OsuAction action) => AddAssert($"Not pressing {action}", () => !osuInputManager.PressedActions.Contains(action)); private void checkPressed(OsuAction action) => AddAssert($"Is pressing {action}", () => osuInputManager.PressedActions.Contains(action)); - public partial class TestActionKeyCounter : KeyCounter, IKeyBindingHandler + public partial class TestActionKeyCounterTrigger : InputTrigger, IKeyBindingHandler { public OsuAction Action { get; } - public TestActionKeyCounter(OsuAction action) + public TestActionKeyCounterTrigger(OsuAction action) : base(action.ToString()) { Action = action; @@ -593,8 +629,7 @@ namespace osu.Game.Rulesets.Osu.Tests { if (e.Action == Action) { - IsLit = true; - Increment(); + Activate(); } return false; @@ -602,7 +637,8 @@ namespace osu.Game.Rulesets.Osu.Tests public void OnReleased(KeyBindingReleaseEvent e) { - if (e.Action == Action) IsLit = false; + if (e.Action == Action) + Deactivate(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index 1e9f931b74..4ad78a3190 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -15,6 +15,10 @@ using osuTK.Graphics; using osu.Game.Rulesets.Mods; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Testing; using osu.Game.Beatmaps.Legacy; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -22,6 +26,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Configuration; namespace osu.Game.Rulesets.Osu.Tests { @@ -30,6 +35,27 @@ namespace osu.Game.Rulesets.Osu.Tests { private int depthIndex; + private readonly BindableBool snakingIn = new BindableBool(); + private readonly BindableBool snakingOut = new BindableBool(); + + [SetUpSteps] + public void SetUpSteps() + { + AddToggleStep("toggle snaking", v => + { + snakingIn.Value = v; + snakingOut.Value = v; + }); + } + + [BackgroundDependencyLoader] + private void load() + { + var config = (OsuRulesetConfigManager)RulesetConfigs.GetConfigFor(Ruleset.Value.CreateInstance()).AsNonNull(); + config.BindWith(OsuRulesetSetting.SnakingInSliders, snakingIn); + config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut); + } + [Test] public void TestVariousSliders() { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs index a32f0a13b8..fc2e6d1f72 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs @@ -7,7 +7,6 @@ using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -47,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Tests { StartTime = time_slider_start, Position = new Vector2(0, 0), - DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = velocity }, + SliderVelocity = velocity, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 5f27cdc191..d83926ab9b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -8,7 +8,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Screens; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -350,7 +349,7 @@ namespace osu.Game.Rulesets.Osu.Tests { StartTime = time_slider_start, Position = new Vector2(0, 0), - DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = 0.1f }, + SliderVelocity = 0.1f, Path = new SliderPath(PathType.PerfectCurve, new[] { Vector2.Zero, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 74d0fb42a3..8cfd674f88 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Osu.Tests EndTime = Time.Current + delay + length, Samples = new List { - new HitSampleInfo("hitnormal") + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 29e6fc4301..f4257a9ee7 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -399,7 +399,7 @@ namespace osu.Game.Rulesets.Osu.Tests { public TestSlider() { - DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = 0.1f }; + SliderVelocity = 0.1f; DefaultsApplied += _ => { diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index e9518895be..d03ee81f0d 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Osu.Beatmaps { var positionData = original as IHasPosition; var comboData = original as IHasCombo; + var sliderVelocityData = original as IHasSliderVelocity; + var generateTicksData = original as IHasGenerateTicks; switch (original) { @@ -47,7 +49,9 @@ namespace osu.Game.Rulesets.Osu.Beatmaps LegacyLastTickOffset = (original as IHasLegacyLastTickOffset)?.LegacyLastTickOffset, // prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance. // this results in more (or less) ticks being generated in true; protected override bool ShouldBeAlive => base.ShouldBeAlive - || (ShowHitMarkers.Value && editorClock.CurrentTime >= Item.StartTime && editorClock.CurrentTime - Item.GetEndTime() < HitCircleOverlapMarker.FADE_OUT_EXTENSION); + || (DrawableObject is not DrawableSpinner && ShowHitMarkers.Value && editorClock.CurrentTime >= Item.StartTime && editorClock.CurrentTime - Item.GetEndTime() < HitCircleOverlapMarker.FADE_OUT_EXTENSION); protected OsuSelectionBlueprint(T hitObject) : base(hitObject) 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 17d0fc457a..c56ffcb140 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -309,7 +309,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { - var result = snapProvider?.FindSnappedPositionAndTime(Parent.ToScreenSpace(e.MousePosition)); + var result = snapProvider?.FindSnappedPositionAndTime(Parent.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? Parent.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 77393efeb3..966092c6fe 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -10,7 +10,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -42,6 +41,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders [Resolved(CanBeNull = true)] private IDistanceSnapProvider snapProvider { get; set; } + protected override bool IsValidForPlacement => HitObject.Path.HasValidLength; + public SliderPlacementBlueprint() : base(new Slider()) { @@ -83,11 +84,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders case SliderPlacementState.Initial: BeginPlacement(); - var nearestDifficultyPoint = editorBeatmap.HitObjects - .LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime)? - .DifficultyControlPoint?.DeepClone() as DifficultyControlPoint; + double? nearestSliderVelocity = (editorBeatmap.HitObjects + .LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime) as Slider)?.SliderVelocity; - HitObject.DifficultyControlPoint = nearestDifficultyPoint ?? new DifficultyControlPoint(); + HitObject.SliderVelocity = nearestSliderVelocity ?? 1; HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); // Replacing the DifficultyControlPoint above doesn't trigger any kind of invalidation. @@ -152,7 +152,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void endCurve() { updateSlider(); - EndPlacement(HitObject.Path.HasValidLength); + EndPlacement(true); } protected override void Update() @@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Update the cursor position. - var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.Body ? SnapType.GlobalGrids : SnapType.All); cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position; } else if (cursor != null) diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index e444287b73..6685507ee0 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -311,17 +310,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders var splitControlPoints = controlPoints.Take(index + 1).ToList(); controlPoints.RemoveRange(0, index); - // Turn the control points which were split off into a new slider. - var samplePoint = (SampleControlPoint)HitObject.SampleControlPoint.DeepClone(); - var difficultyPoint = (DifficultyControlPoint)HitObject.DifficultyControlPoint.DeepClone(); - var newSlider = new Slider { StartTime = HitObject.StartTime, Position = HitObject.Position + splitControlPoints[0].Position, NewCombo = HitObject.NewCombo, - SampleControlPoint = samplePoint, - DifficultyControlPoint = difficultyPoint, LegacyLastTickOffset = HitObject.LegacyLastTickOffset, Samples = HitObject.Samples.Select(s => s.With()).ToList(), RepeatCount = HitObject.RepeatCount, @@ -378,15 +371,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders Vector2 position = HitObject.Position + HitObject.Path.PositionAt(pathPosition); - var samplePoint = (SampleControlPoint)HitObject.SampleControlPoint.DeepClone(); - samplePoint.Time = time; - editorBeatmap.Add(new HitCircle { StartTime = time, Position = position, NewCombo = i == 0 && HitObject.NewCombo, - SampleControlPoint = samplePoint, Samples = HitObject.HeadCircle.Samples.Select(s => s.With()).ToList() }); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs index 73ee5df9dc..f59be0e0e9 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -24,9 +21,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners private bool isPlacingEnd; - [Resolved(CanBeNull = true)] - [CanBeNull] - private IBeatSnapProvider beatSnapProvider { get; set; } + [Resolved] + private IBeatSnapProvider? beatSnapProvider { get; set; } public SpinnerPlacementBlueprint() : base(new Spinner { Position = OsuPlayfield.BASE_SIZE / 2 }) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 7a70257f3a..ad6af6d74e 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -13,8 +13,8 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Utils; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -62,7 +62,12 @@ namespace osu.Game.Rulesets.Osu.Edit private void load() { // Give a bit of breathing room around the playfield content. - PlayfieldContentContainer.Padding = new MarginPadding(10); + PlayfieldContentContainer.Padding = new MarginPadding + { + Vertical = 10, + Left = TOOLBOX_CONTRACTED_SIZE_LEFT + 10, + Right = TOOLBOX_CONTRACTED_SIZE_RIGHT + 10, + }; LayerBelowRuleset.AddRange(new Drawable[] { @@ -138,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Edit // 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.HasFlagFast(SnapType.Grids) && DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (snapType.HasFlagFast(SnapType.RelativeGrids) && 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)) @@ -150,7 +155,7 @@ namespace osu.Game.Rulesets.Osu.Edit SnapResult result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); - if (snapType.HasFlagFast(SnapType.Grids)) + if (snapType.HasFlagFast(SnapType.RelativeGrids)) { if (DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { @@ -159,7 +164,10 @@ namespace osu.Game.Rulesets.Osu.Edit result.ScreenSpacePosition = distanceSnapGrid.ToScreenSpace(pos); result.Time = time; } + } + if (snapType.HasFlagFast(SnapType.GlobalGrids)) + { if (rectangularGridSnapToggle.Value == TernaryState.True) { Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(result.ScreenSpacePosition)); diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 6d5280e528..2a6d6ce4c3 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -362,7 +362,6 @@ namespace osu.Game.Rulesets.Osu.Edit StartTime = firstHitObject.StartTime, Position = firstHitObject.Position, NewCombo = firstHitObject.NewCombo, - SampleControlPoint = firstHitObject.SampleControlPoint, Samples = firstHitObject.Samples, }; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 4782a0e49c..3841c9c716 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -24,7 +24,17 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Automation; public override LocalisableString Description => @"Automatic cursor movement - just follow the rhythm."; public override double ScoreMultiplier => 0.1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised), typeof(OsuModRepel) }; + + public override Type[] IncompatibleMods => new[] + { + typeof(OsuModSpunOut), + typeof(ModRelax), + typeof(ModFailCondition), + typeof(ModNoFail), + typeof(ModAutoplay), + typeof(OsuModMagnetised), + typeof(OsuModRepel) + }; public bool PerformFail() => false; @@ -34,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Mods private List replayFrames = null!; - private int currentFrame; + private int currentFrame = -1; public void Update(Playfield playfield) { @@ -43,8 +53,9 @@ namespace osu.Game.Rulesets.Osu.Mods double time = playfield.Clock.CurrentTime; // Very naive implementation of autopilot based on proximity to replay frames. + // Special case for the first frame is required to ensure the mouse is in a sane position until the actual time of the first frame is hit. // TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered). - if (Math.Abs(replayFrames[currentFrame + 1].Time - time) <= Math.Abs(replayFrames[currentFrame].Time - time)) + if (currentFrame < 0 || Math.Abs(replayFrames[currentFrame + 1].Time - time) <= Math.Abs(replayFrames[currentFrame].Time - time)) { currentFrame++; new MousePositionAbsoluteInput { Position = playfield.ToScreenSpace(replayFrames[currentFrame].Position) }.Apply(inputManager.CurrentState, inputManager); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs index 9e71f657ce..2394cf92fc 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -10,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModBarrelRoll : ModBarrelRoll, IApplicableToDrawableHitObject { + public override Type[] IncompatibleMods => new[] { typeof(OsuModBubbles) }; + public void ApplyToDrawableHitObject(DrawableHitObject d) { d.OnUpdate += _ => diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs new file mode 100644 index 0000000000..b74b722bad --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.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; +using System.Diagnostics; +using System.Linq; +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.Effects; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public partial class OsuModBubbles : Mod, IApplicableToDrawableRuleset, IApplicableToDrawableHitObject, IApplicableToScoreProcessor + { + public override string Name => "Bubbles"; + + public override string Acronym => "BU"; + + public override LocalisableString Description => "Don't let their popping distract you!"; + + public override double ScoreMultiplier => 1; + + 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 + public override Type[] IncompatibleMods => new[] { typeof(OsuModBarrelRoll), typeof(OsuModMagnetised), typeof(OsuModRepel) }; + + private PlayfieldAdjustmentContainer bubbleContainer = null!; + + private DrawablePool bubblePool = null!; + + private readonly Bindable currentCombo = new BindableInt(); + + private float maxSize; + private float bubbleSize; + private double bubbleFade; + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + currentCombo.BindTo(scoreProcessor.Combo); + currentCombo.BindValueChanged(combo => + maxSize = Math.Min(1.75f, (float)(1.25 + 0.005 * combo.NewValue)), true); + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + // Multiplying by 2 results in an initial size that is too large, hence 1.90 has been chosen + // Also avoids the HitObject bleeding around the edges of the bubble drawable at minimum size + bubbleSize = (float)(drawableRuleset.Beatmap.HitObjects.OfType().First().Radius * 1.90f); + bubbleFade = drawableRuleset.Beatmap.HitObjects.OfType().First().TimePreempt * 2; + + // We want to hide the judgements since they are obscured by the BubbleDrawable (due to layering) + drawableRuleset.Playfield.DisplayJudgements.Value = false; + + bubbleContainer = drawableRuleset.CreatePlayfieldAdjustmentContainer(); + + drawableRuleset.Overlays.Add(bubbleContainer); + drawableRuleset.Overlays.Add(bubblePool = new DrawablePool(100)); + } + + public void ApplyToDrawableHitObject(DrawableHitObject drawableObject) + { + drawableObject.OnNewResult += (drawable, _) => + { + if (drawable is not DrawableOsuHitObject drawableOsuHitObject) return; + + switch (drawableOsuHitObject.HitObject) + { + case Slider: + case SpinnerTick: + break; + + default: + addBubble(); + break; + } + + void addBubble() + { + BubbleDrawable bubble = bubblePool.Get(); + + bubble.DrawableOsuHitObject = drawableOsuHitObject; + bubble.InitialSize = new Vector2(bubbleSize); + bubble.FadeTime = bubbleFade; + bubble.MaxSize = maxSize; + + bubbleContainer.Add(bubble); + } + }; + + drawableObject.OnRevertResult += (drawable, _) => + { + if (drawable.HitObject is SpinnerTick or Slider) return; + + BubbleDrawable? lastBubble = bubbleContainer.OfType().LastOrDefault(); + + lastBubble?.ClearTransforms(); + lastBubble?.Expire(true); + }; + } + + #region Pooled Bubble drawable + + private partial class BubbleDrawable : PoolableDrawable + { + public DrawableOsuHitObject? DrawableOsuHitObject { get; set; } + + public Vector2 InitialSize { get; set; } + + public float MaxSize { get; set; } + + public double FadeTime { get; set; } + + private readonly Box colourBox; + private readonly CircularContainer content; + + public BubbleDrawable() + { + Origin = Anchor.Centre; + InternalChild = content = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + MaskingSmoothness = 2, + BorderThickness = 0, + BorderColour = Colour4.White, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 3, + Colour = Colour4.Black.Opacity(0.05f), + }, + Child = colourBox = new Box { RelativeSizeAxes = Axes.Both, } + }; + } + + protected override void PrepareForUse() + { + Debug.Assert(DrawableOsuHitObject.IsNotNull()); + + Colour = DrawableOsuHitObject.IsHit ? Colour4.White : Colour4.Black; + Scale = new Vector2(1); + Position = getPosition(DrawableOsuHitObject); + Size = InitialSize; + + //We want to fade to a darker colour to avoid colours such as white hiding the "ripple" effect. + ColourInfo colourDarker = DrawableOsuHitObject.AccentColour.Value.Darken(0.1f); + + // The absolute length of the bubble's animation, can be used in fractions for animations of partial length + double duration = 1700 + Math.Pow(FadeTime, 1.07f); + + // Main bubble scaling based on combo + this.FadeTo(1) + .ScaleTo(MaxSize, duration * 0.8f) + .Then() + // Pop at the end of the bubbles life time + .ScaleTo(MaxSize * 1.5f, duration * 0.2f, Easing.OutQuint) + .FadeOut(duration * 0.2f, Easing.OutCirc).Expire(); + + if (!DrawableOsuHitObject.IsHit) return; + + content.BorderThickness = InitialSize.X / 3.5f; + content.BorderColour = Colour4.White; + + colourBox.FadeColour(colourDarker); + + content.TransformTo(nameof(BorderColour), colourDarker, duration * 0.3f, Easing.OutQuint); + // Ripple effect utilises the border to reduce drawable count + content.TransformTo(nameof(BorderThickness), 2f, duration * 0.3f, Easing.OutQuint) + .Then() + // Avoids transparency overlap issues during the bubble "pop" + .TransformTo(nameof(BorderThickness), 0f); + } + + private Vector2 getPosition(DrawableOsuHitObject drawableObject) + { + switch (drawableObject) + { + // SliderHeads are derived from HitCircles, + // so we must handle them before to avoid them using the wrong positioning logic + case DrawableSliderHead: + return drawableObject.HitObject.Position; + + // Using hitobject position will cause issues with HitCircle placement due to stack leniency. + case DrawableHitCircle: + return drawableObject.Position; + + default: + return drawableObject.HitObject.Position; + } + } + } + + #endregion + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.cs index 371dfe6a1a..1de6b9ce55 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDaycore.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModDaycore : ModDaycore { - public override double ScoreMultiplier => 0.3; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs index 700a3f44bc..5569df8d95 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModDoubleTime : ModDoubleTime { - public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.cs index 4769e7660b..bf65a6c9d3 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHalfTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModHalfTime : ModHalfTime { - public override double ScoreMultiplier => 0.3; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index 38d90eb121..c8c4cd6a14 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Mods 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; - public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel), typeof(OsuModBubbles) }; [SettingSource("Attraction strength", "How strong the pull is.", 0)] public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs index b7838ebaa7..661cc948c5 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs @@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModNightcore : ModNightcore { - public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 307d731fd4..3224ff9eaf 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -9,7 +9,6 @@ using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; @@ -28,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModTargetPractice)).ToArray(); - [SettingSource("Angle sharpness", "How sharp angles should be", SettingControlType = typeof(SettingsSlider))] + [SettingSource("Angle sharpness", "How sharp angles should be")] public BindableFloat AngleSharpness { get; } = new BindableFloat(7) { MinValue = 1, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs index 31a6b69d6b..28d459cedb 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Fun; public override LocalisableString Description => "Hit objects run away!"; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised), typeof(OsuModBubbles) }; [SettingSource("Repulsion strength", "How strong the repulsion is.", 0)] public BindableFloat RepulsionStrength { get; } = new BindableFloat(0.5f) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 7e4ffc7408..72031b4958 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -98,6 +98,7 @@ namespace osu.Game.Rulesets.Osu.Mods ComboOffset = original.ComboOffset; LegacyLastTickOffset = original.LegacyLastTickOffset; TickDistanceMultiplier = original.TickDistanceMultiplier; + SliderVelocity = original.SliderVelocity; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index a7b02596d5..664a8146e7 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -133,14 +133,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. - if (HitObject.SampleControlPoint == null) - { - throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}." - + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); - } - - Samples.Samples = HitObject.TailSamples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); - slidingSample.Samples = HitObject.CreateSlidingSamples().Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + Samples.Samples = HitObject.TailSamples.Cast().ToArray(); + slidingSample.Samples = HitObject.CreateSlidingSamples().Cast().ToArray(); } public override void StopAllSamples() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 3446d41fb4..fc4863f164 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -87,12 +87,15 @@ 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; + animDuration = Math.Min(300, HitObject.SpanDuration); - this.Animate( - d => d.FadeIn(animDuration), - d => d.ScaleTo(0.5f).ScaleTo(1f, animDuration * 2, Easing.OutElasticHalf) - ); + this + .FadeOut() + .Delay(delayFadeIn ? (Slider?.TimePreempt ?? 0) / 3 : 0) + .FadeIn(HitObject.RepeatIndex == 0 ? HitObject.TimeFadeIn : animDuration); } protected override void UpdateHitStateTransforms(ArmedState state) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 2c1b68e05a..d9501f7d58 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -91,7 +91,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateInitialTransforms(); - CirclePiece.FadeInFromZero(HitObject.TimeFadeIn); + // 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); } 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 a5193f1b6e..0ceda1d4b0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -126,7 +126,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.LoadSamples(); - spinningSample.Samples = HitObject.CreateSpinningSamples().Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + spinningSample.Samples = HitObject.CreateSpinningSamples().Cast().ToArray(); spinningSample.Frequency.Value = spinning_sample_initial_frequency; } diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 6c2be8a49a..4189f8ba1e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -10,18 +10,18 @@ using osu.Game.Rulesets.Objects; using System.Linq; using System.Threading; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Beatmaps.Formats; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { - public class Slider : OsuHitObject, IHasPathWithRepeats + public class Slider : OsuHitObject, IHasPathWithRepeats, IHasSliderVelocity, IHasGenerateTicks { public double EndTime => StartTime + this.SpanCount() * Path.Distance / Velocity; @@ -134,6 +134,21 @@ namespace osu.Game.Rulesets.Osu.Objects /// public bool OnlyJudgeNestedObjects = true; + public BindableNumber SliderVelocityBindable { get; } = new BindableDouble(1) + { + Precision = 0.01, + MinValue = 0.1, + MaxValue = 10 + }; + + public double SliderVelocity + { + get => SliderVelocityBindable.Value; + set => SliderVelocityBindable.Value = value; + } + + public bool GenerateTicks { get; set; } = true; + [JsonIgnore] public SliderHeadCircle HeadCircle { get; protected set; } @@ -151,15 +166,11 @@ namespace osu.Game.Rulesets.Osu.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); -#pragma warning disable 618 - var legacyDifficultyPoint = DifficultyControlPoint as LegacyBeatmapDecoder.LegacyDifficultyControlPoint; -#pragma warning restore 618 - double scoringDistance = BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; - bool generateTicks = legacyDifficultyPoint?.GenerateTicks ?? true; + double scoringDistance = BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; - TickDistance = generateTicks ? (scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier) : double.PositiveInfinity; + TickDistance = GenerateTicks ? (scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier) : double.PositiveInfinity; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs index 35bec92354..f52c3ab382 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs @@ -39,11 +39,8 @@ namespace osu.Game.Rulesets.Osu.Objects } else { - // taken from osu-stable - const float first_end_circle_preempt_adjust = 2 / 3f; - // The first end circle should fade in with the slider. - TimePreempt = (StartTime - slider.StartTime) + slider.TimePreempt * first_end_circle_preempt_adjust; + TimePreempt += StartTime - slider.StartTime; } } diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index ed6f8a9a6a..ba0981e781 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.Objects AddNested(i < SpinsRequired ? new SpinnerTick { StartTime = startTime, SpinnerDuration = Duration } - : new SpinnerBonusTick { StartTime = startTime, SpinnerDuration = Duration }); + : new SpinnerBonusTick { StartTime = startTime, SpinnerDuration = Duration, Samples = new[] { CreateHitSampleInfo("spinnerbonus") } }); } } @@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Objects return new[] { - SampleControlPoint.ApplyTo(referenceSample).With("spinnerspin") + referenceSample.With("spinnerspin") }; } } diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs index 81cdf5755b..00ceccaf7b 100644 --- a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs @@ -3,7 +3,6 @@ #nullable disable -using osu.Game.Audio; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -11,11 +10,6 @@ namespace osu.Game.Rulesets.Osu.Objects { public class SpinnerBonusTick : SpinnerTick { - public SpinnerBonusTick() - { - Samples.Add(new HitSampleInfo("spinnerbonus")); - } - public override Judgement CreateJudgement() => new OsuSpinnerBonusTickJudgement(); public class OsuSpinnerBonusTickJudgement : OsuSpinnerTickJudgement diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 48056a49de..8ce55d78dd 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -203,7 +203,8 @@ namespace osu.Game.Rulesets.Osu new OsuModNoScope(), new MultiMod(new OsuModMagnetised(), new OsuModRepel()), new ModAdaptiveSpeed(), - new OsuModFreezeFrame() + new OsuModFreezeFrame(), + new OsuModBubbles() }; case ModType.System: @@ -290,56 +291,32 @@ namespace osu.Game.Rulesets.Osu return base.GetDisplayNameForHitResult(result); } - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { var timedHitEvents = score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList(); return new[] { - new StatisticRow + new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }), + new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) { - Columns = new[] - { - new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem("Accuracy Heatmap", () => new AccuracyHeatmap(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Accuracy Heatmap", () => new AccuracyHeatmap(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] { - Columns = new[] - { - new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] - { - new AverageHitError(timedHitEvents), - new UnstableRate(timedHitEvents) - }), true) - } - } + new AverageHitError(timedHitEvents), + new UnstableRate(timedHitEvents) + }), true) }; } diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index 8fdf3821fa..52fdfea95f 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -10,6 +10,7 @@ namespace osu.Game.Rulesets.Osu Cursor, CursorTrail, CursorParticles, + CursorRipple, SliderScorePoint, ReverseArrow, HitCircleText, diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs index 50d4eb6258..f97be0d7ff 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.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 osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring @@ -18,21 +15,14 @@ namespace osu.Game.Rulesets.Osu.Scoring { } - protected override double ClassicScoreMultiplier => 36; - protected override HitEvent CreateHitEvent(JudgementResult result) => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit); - protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { - switch (hitObject) - { - case HitCircle: - return new OsuHitCircleJudgementResult(hitObject, judgement); - - default: - return new OsuJudgementResult(hitObject, judgement); - } + return 700000 * comboProgress + + 300000 * Math.Pow(Accuracy.Value, 10) * accuracyProgress + + bonusPortion; } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs index d6ce793c7e..461b4a3b45 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs @@ -98,7 +98,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) { - this.FadeOut(duration, Easing.OutQuint); + // intentionally pile on an extra FadeOut to make it happen much faster + this.FadeOut(duration / 4, Easing.OutQuint); icon.ScaleTo(defaultIconScale * icon_scale, duration, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs index fbe094ef81..e6166e9441 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs @@ -3,11 +3,13 @@ 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; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { @@ -18,6 +20,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private Drawable proxy = null!; + private Bindable accentColour = null!; + + private bool textureIsDefaultSkin; + + private Drawable arrow = null!; + [BackgroundDependencyLoader] private void load(ISkinSource skinSource) { @@ -26,7 +34,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy string lookupName = new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow).LookupName; var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null); - InternalChild = skin?.GetAnimation(lookupName, true, true) ?? Empty(); + + InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true) ?? Empty()); + textureIsDefaultSkin = skin is ISkinTransformer transformer && transformer.Skin is DefaultLegacySkin; } protected override void LoadComplete() @@ -39,6 +49,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { drawableHitObject.HitObjectApplied += onHitObjectApplied; onHitObjectApplied(drawableHitObject); + + accentColour = drawableHitObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(c => + { + arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White; + }, true); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 620540b8ef..f049aa088f 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -100,6 +100,28 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return null; + case OsuSkinComponents.CursorRipple: + if (GetTexture("cursor-ripple") != null) + { + var ripple = this.GetAnimation("cursor-ripple", false, false); + + // In stable this element was scaled down to 50% and opacity 20%, but this makes the elements WAY too big and inflexible. + // If anyone complains about these not being applied, this can be uncommented. + // + // But if no one complains I'd rather fix this in lazer. Wiki documentation doesn't mention size, + // so we might be okay. + // + // if (ripple != null) + // { + // ripple.Scale = new Vector2(0.5f); + // ripple.Alpha = 0.2f; + // } + + return ripple; + } + + return null; + case OsuSkinComponents.CursorParticles: if (GetTexture("star2") != null) return new LegacyCursorParticles(); diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs index a824f202ec..9d64c354e2 100644 --- a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -252,13 +252,14 @@ namespace osu.Game.Rulesets.Osu.Skinning renderer.SetBlend(BlendingParameters.Additive); renderer.PushLocalMatrix(DrawInfo.Matrix); - TextureShader.Bind(); + BindTextureShader(renderer); + texture.Bind(); for (int i = 0; i < points.Count; i++) drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex); - TextureShader.Unbind(); + UnbindTextureShader(renderer); renderer.PopLocalMatrix(); } diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs index 0249b6d9b1..5d2f6a14c7 100644 --- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.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; @@ -13,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Scoring; using osuTK; @@ -36,8 +35,8 @@ namespace osu.Game.Rulesets.Osu.Statistics private const float rotation = 45; - private BufferedContainer bufferedGrid; - private GridContainer pointGrid; + private BufferedContainer bufferedGrid = null!; + private GridContainer pointGrid = null!; private readonly ScoreInfo score; private readonly IBeatmap playableBeatmap; @@ -58,6 +57,8 @@ namespace osu.Game.Rulesets.Osu.Statistics [BackgroundDependencyLoader] private void load() { + const float line_extension = 0.2f; + InternalChild = new Container { Anchor = Anchor.Centre, @@ -66,76 +67,99 @@ namespace osu.Game.Rulesets.Osu.Statistics FillMode = FillMode.Fit, Children = new Drawable[] { - new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(inner_portion), - Masking = true, - BorderThickness = line_thickness, - BorderColour = Color4.White, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex("#202624") - } - }, new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(inner_portion), + Masking = true, + BorderThickness = line_thickness, + BorderColour = Color4.White, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#202624") + } + }, new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding(1), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Rotation = rotation, Child = new Container { RelativeSizeAxes = Axes.Both, - Masking = true, Children = new Drawable[] { - new Box + new Circle { Anchor = Anchor.Centre, Origin = Anchor.Centre, - EdgeSmoothness = new Vector2(1), RelativeSizeAxes = Axes.Y, - Height = 2, // We're rotating along a diagonal - we don't really care how big this is. - Width = line_thickness / 2, - Rotation = -rotation, - Alpha = 0.3f, + Width = line_thickness, + Height = inner_portion + line_extension, + Rotation = -rotation * 2, + Alpha = 0.6f, }, - new Box + new Circle { Anchor = Anchor.Centre, Origin = Anchor.Centre, - EdgeSmoothness = new Vector2(1), RelativeSizeAxes = Axes.Y, - Height = 2, // We're rotating along a diagonal - we don't really care how big this is. - Width = line_thickness / 2, // adjust for edgesmoothness - Rotation = rotation + Width = line_thickness, + Height = inner_portion + line_extension, }, + new OsuSpriteText + { + Text = "Overshoot", + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + Padding = new MarginPadding(3), + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + line_extension) / 2, + }, + new OsuSpriteText + { + Text = "Undershoot", + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Padding = new MarginPadding(3), + RelativePositionAxes = Axes.Both, + Y = (inner_portion + line_extension) / 2, + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + line_extension) / 2, + Margin = new MarginPadding(-line_thickness / 2), + Width = line_thickness, + Height = 10, + Rotation = 45, + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + line_extension) / 2, + Margin = new MarginPadding(-line_thickness / 2), + Width = line_thickness, + Height = 10, + Rotation = -45, + } } }, }, - new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Width = 10, - EdgeSmoothness = new Vector2(1), - Height = line_thickness / 2, // adjust for edgesmoothness - }, - new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - EdgeSmoothness = new Vector2(1), - Width = line_thickness / 2, // adjust for edgesmoothness - Height = 10, - } } }, bufferedGrid = new BufferedContainer(cachedFrameBuffer: true) diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorRippleVisualiser.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorRippleVisualiser.cs new file mode 100644 index 0000000000..076d97d06a --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorRippleVisualiser.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +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.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI.Cursor +{ + public partial class CursorRippleVisualiser : CompositeDrawable, IKeyBindingHandler + { + private readonly Bindable showRipples = new Bindable(true); + + private readonly DrawablePool ripplePool = new DrawablePool(20); + + public CursorRippleVisualiser() + { + RelativeSizeAxes = Axes.Both; + } + + public Vector2 CursorScale { get; set; } = Vector2.One; + + [BackgroundDependencyLoader(true)] + private void load(OsuRulesetConfigManager? rulesetConfig) + { + rulesetConfig?.BindWith(OsuRulesetSetting.ShowCursorRipples, showRipples); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (showRipples.Value) + { + AddInternal(ripplePool.Get(r => + { + r.Position = e.MousePosition; + r.Scale = CursorScale; + })); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + private partial class CursorRipple : PoolableDrawable + { + private Drawable ripple = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + + InternalChild = ripple = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorRipple), _ => new DefaultCursorRipple()) + { + Blending = BlendingParameters.Additive, + }; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + ClearTransforms(true); + + ripple.ScaleTo(0.1f) + .ScaleTo(1, 700, Easing.Out); + + this + .FadeOutFromOne(700) + .Expire(true); + } + } + + public partial class DefaultCursorRipple : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new RingPiece(3) + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2), + Alpha = 0.1f, + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 5d7648b073..bf1ff872dd 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -40,6 +40,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private Bindable userCursorScale; private Bindable autoCursorScale; + private readonly CursorRippleVisualiser rippleVisualiser; + public OsuCursorContainer() { InternalChild = fadeContainer = new Container @@ -48,6 +50,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor Children = new[] { cursorTrail = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling), + rippleVisualiser = new CursorRippleVisualiser(), new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorParticles), confineMode: ConfineMode.NoScaling), } }; @@ -82,6 +85,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor var newScale = new Vector2(e.NewValue); ActiveCursor.Scale = newScale; + rippleVisualiser.CursorScale = newScale; cursorTrail.Scale = newScale; }, true); diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index e951197643..555610a3b6 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Osu.UI localCursorContainer?.Expire(); localCursorContainer = null; - GameplayCursor?.ActiveCursor?.Show(); + GameplayCursor?.ActiveCursor.Show(); } protected override bool OnHover(HoverEvent e) => true; diff --git a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs index 64c4e7eef6..0e410dbf57 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs @@ -43,6 +43,11 @@ namespace osu.Game.Rulesets.Osu.UI LabelText = RulesetSettingsStrings.CursorTrail, Current = config.GetBindable(OsuRulesetSetting.ShowCursorTrail) }, + new SettingsCheckbox + { + LabelText = RulesetSettingsStrings.CursorRipples, + Current = config.GetBindable(OsuRulesetSetting.ShowCursorRipples) + }, new SettingsEnumDropdown { LabelText = RulesetSettingsStrings.PlayfieldBorderStyle, diff --git a/osu.Game.Rulesets.Osu/UI/OsuTouchInputMapper.cs b/osu.Game.Rulesets.Osu/UI/OsuTouchInputMapper.cs index 8df1c35b5c..5277a1f7d6 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuTouchInputMapper.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuTouchInputMapper.cs @@ -22,6 +22,13 @@ namespace osu.Game.Rulesets.Osu.UI /// private readonly List trackedTouches = new List(); + /// + /// The distance (in local pixels) that a touch must move before being considered a permanent tracking touch. + /// After this distance is covered, any extra touches on the screen will be considered as button inputs, unless + /// a new touch directly interacts with a hit circle. + /// + private const float distance_before_position_tracking_lock_in = 100; + private TrackedTouch? positionTrackingTouch; private readonly OsuInputManager osuInputManager; @@ -97,26 +104,32 @@ namespace osu.Game.Rulesets.Osu.UI return; } - // ..or if the current position tracking touch was not a direct touch (this one is debatable and may be change in the future, but it's the simplest way to handle) - if (!positionTrackingTouch.DirectTouch) + // ..or if the current position tracking touch was not a direct touch (and didn't travel across the screen too far). + if (!positionTrackingTouch.DirectTouch && positionTrackingTouch.DistanceTravelled < distance_before_position_tracking_lock_in) { positionTrackingTouch = newTouch; return; } // In the case the new touch was not used for position tracking, we should also check the previous position tracking touch. - // If it was a direct touch and still has its action pressed, that action should be released. + // If it still has its action pressed, that action should be released. // // This is done to allow tracking with the initial touch while still having both Left/Right actions available for alternating with two more touches. - if (positionTrackingTouch.DirectTouch && positionTrackingTouch.Action is OsuAction directTouchAction) + if (positionTrackingTouch.Action is OsuAction touchAction) { - osuInputManager.KeyBindingContainer.TriggerReleased(directTouchAction); + osuInputManager.KeyBindingContainer.TriggerReleased(touchAction); positionTrackingTouch.Action = null; } } private void handleTouchMovement(TouchEvent touchEvent) { + if (touchEvent is TouchMoveEvent moveEvent) + { + var trackedTouch = trackedTouches.Single(t => t.Source == touchEvent.Touch.Source); + trackedTouch.DistanceTravelled += moveEvent.Delta.Length; + } + // Movement should only be tracked for the most recent touch. if (touchEvent.Touch.Source != positionTrackingTouch?.Source) return; @@ -148,8 +161,16 @@ namespace osu.Game.Rulesets.Osu.UI public OsuAction? Action; + /// + /// Whether the touch was on a hit circle receptor. + /// public readonly bool DirectTouch; + /// + /// The total distance on screen travelled by this touch (in local pixels). + /// + public float DistanceTravelled; + public TrackedTouch(TouchSource source, OsuAction? action, bool directTouch) { Source = source; diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/AppDelegate.cs b/osu.Game.Rulesets.Taiko.Tests.iOS/AppDelegate.cs deleted file mode 100644 index 385ba48707..0000000000 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using Foundation; -using osu.Framework.iOS; -using osu.Game.Tests; - -namespace osu.Game.Rulesets.Taiko.Tests.iOS -{ - [Register("AppDelegate")] - public class AppDelegate : GameAppDelegate - { - 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/Application.cs index 0e3a953728..0b6a11d8c2 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Application.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 UIKit; +using osu.Framework.iOS; +using osu.Game.Tests; namespace osu.Game.Rulesets.Taiko.Tests.iOS { @@ -11,7 +10,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, null, typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs index e4e68c7207..dd8748f6e3 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -91,8 +91,9 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { prepareDrawableRulesetAndBeatmap(false); - assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); - assertStateAfterResult(new JudgementResult(new Hit.StrongNestedHit(), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Idle); + var hit = new Hit(); + assertStateAfterResult(new JudgementResult(hit, new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); + assertStateAfterResult(new JudgementResult(new Hit.StrongNestedHit(hit), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Idle); } [Test] diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs new file mode 100644 index 0000000000..287d90b406 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs @@ -0,0 +1,337 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public partial class TestSceneDrumSampleTriggerSource : OsuTestScene + { + private readonly ManualClock manualClock = new ManualClock(); + + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 200 }, + }; + + private ScrollingHitObjectContainer hitObjectContainer = null!; + private TestDrumSampleTriggerSource triggerSource = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + hitObjectContainer = new ScrollingHitObjectContainer(); + manualClock.CurrentTime = 0; + + Child = new Container + { + Clock = new FramedClock(manualClock), + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + hitObjectContainer, + triggerSource = new TestDrumSampleTriggerSource(hitObjectContainer) + } + }; + }); + + [Test] + public void TestNormalHit() + { + AddStep("add hit with normal samples", () => + { + var hit = new Hit + { + StartTime = 100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + } + }; + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableHit = new DrawableHit(hit); + hitObjectContainer.Add(drawableHit); + }); + + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek past hit", () => manualClock.CurrentTime = 200); + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + } + + [Test] + public void TestSoftHit() + { + AddStep("add hit with soft samples", () => + { + var hit = new Hit + { + StartTime = 100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) + } + }; + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableHit = new DrawableHit(hit); + hitObjectContainer.Add(drawableHit); + }); + + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + + AddStep("seek past hit", () => manualClock.CurrentTime = 200); + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + } + + [Test] + public void TestDrumStrongHit() + { + AddStep("add strong hit with drum samples", () => + { + var hit = new Hit + { + StartTime = 100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + } + }; + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableHit = new DrawableHit(hit); + hitObjectContainer.Add(drawableHit); + }); + + AddAssert("most valid object is strong nested hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek past hit", () => manualClock.CurrentTime = 200); + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + } + + [Test] + public void TestNormalDrumRoll() + { + AddStep("add drum roll with normal samples", () => + { + var drumRoll = new DrumRoll + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + } + }; + drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableDrumRoll = new DrawableDrumRoll(drumRoll); + hitObjectContainer.Add(drawableDrumRoll); + }); + + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + } + + [Test] + public void TestSoftDrumRoll() + { + AddStep("add drum roll with soft samples", () => + { + var drumRoll = new DrumRoll + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) + } + }; + drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableDrumRoll = new DrawableDrumRoll(drumRoll); + hitObjectContainer.Add(drawableDrumRoll); + }); + + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + + AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + + AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + } + + [Test] + public void TestDrumStrongDrumRoll() + { + AddStep("add strong drum roll with drum samples", () => + { + var drumRoll = new DrumRoll + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + } + }; + drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableDrumRoll = new DrawableDrumRoll(drumRoll); + hitObjectContainer.Add(drawableDrumRoll); + }); + + AddAssert("most valid object is drum roll tick's nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is drum roll tick's nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + } + + [Test] + public void TestNormalSwell() + { + AddStep("add swell with normal samples", () => + { + var swell = new Swell + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + } + }; + swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableSwell = new DrawableSwell(swell); + hitObjectContainer.Add(drawableSwell); + }); + + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek to middle of swell", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek past swell", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + } + + [Test] + public void TestDrumSwell() + { + AddStep("add swell with drum samples", () => + { + var swell = new Swell + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum") + } + }; + swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableSwell = new DrawableSwell(swell); + hitObjectContainer.Add(drawableSwell); + }); + + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek to middle of swell", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek past swell", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + } + + private void checkSound(HitType hitType, string expectedName, string expectedBank) + { + AddStep($"hit {hitType}", () => triggerSource.Play(hitType)); + AddAssert($"last played sample is {expectedName}", () => triggerSource.LastPlayedSamples!.OfType().Single().Name, () => Is.EqualTo(expectedName)); + AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType().Single().Bank, () => Is.EqualTo(expectedBank)); + } + + private partial class TestDrumSampleTriggerSource : DrumSampleTriggerSource + { + public ISampleInfo[]? LastPlayedSamples { get; private set; } + + public TestDrumSampleTriggerSource(HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) + { + } + + protected override void PlaySamples(ISampleInfo[] samples) + { + base.PlaySamples(samples); + LastPlayedSamples = samples; + } + + public new HitObject GetMostValidObject() => base.GetMostValidObject(); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index 6a35e9376b..e298e313df 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -64,7 +64,9 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps foreach (HitObject hitObject in original.HitObjects) { - double nextScrollSpeed = hitObject.DifficultyControlPoint.SliderVelocity; + if (hitObject is not IHasSliderVelocity hasSliderVelocity) continue; + + double nextScrollSpeed = hasSliderVelocity.SliderVelocity; EffectControlPoint currentEffectPoint = converted.ControlPointInfo.EffectPointAt(hitObject.StartTime); if (!Precision.AlmostEquals(lastScrollSpeed, nextScrollSpeed, acceptableDifference: currentEffectPoint.ScrollSpeedBindable.Precision)) @@ -131,7 +133,8 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps StartTime = obj.StartTime, Samples = obj.Samples, Duration = taikoDuration, - TickRate = beatmap.Difficulty.SliderTickRate == 3 ? 3 : 4 + TickRate = beatmap.Difficulty.SliderTickRate == 3 ? 3 : 4, + SliderVelocity = obj is IHasSliderVelocity velocityData ? velocityData.SliderVelocity : 1 }; } @@ -177,15 +180,14 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps double distance = distanceData.Distance * spans * LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); - DifficultyControlPoint difficultyPoint = obj.DifficultyControlPoint; double beatLength; -#pragma warning disable 618 - if (difficultyPoint is LegacyBeatmapDecoder.LegacyDifficultyControlPoint legacyDifficultyPoint) -#pragma warning restore 618 - beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; + if (obj.LegacyBpmMultiplier.HasValue) + beatLength = timingPoint.BeatLength * obj.LegacyBpmMultiplier.Value; + else if (obj is IHasSliderVelocity hasSliderVelocity) + beatLength = timingPoint.BeatLength / hasSliderVelocity.SliderVelocity; else - beatLength = timingPoint.BeatLength / difficultyPoint.SliderVelocity; + beatLength = timingPoint.BeatLength; double sliderScoringPointDistance = osu_base_scoring_distance * beatmap.Difficulty.SliderMultiplier / beatmap.Difficulty.SliderTickRate; diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index 0a1f5380b5..8b1a4f688c 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.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.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Objects; @@ -35,20 +33,11 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints protected override bool OnMouseDown(MouseDownEvent e) { - switch (e.Button) - { - case MouseButton.Left: - HitObject.Type = HitType.Centre; - EndPlacement(true); - return true; + if (e.Button != MouseButton.Left) + return false; - case MouseButton.Right: - HitObject.Type = HitType.Rim; - EndPlacement(true); - return true; - } - - return false; + EndPlacement(true); + return true; } public override void UpdateTimeAndPosition(SnapResult result) diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index fcf2573d64..bc4129c982 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints private readonly IHasDuration spanPlacementObject; + protected override bool IsValidForPlacement => spanPlacementObject.Duration > 0; + public TaikoSpanPlacementBlueprint(HitObject hitObject) : base(hitObject) { @@ -73,7 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(spanPlacementObject.Duration > 0); + EndPlacement(true); } public override void UpdateTimeAndPosition(SnapResult result) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs index d0361b1c8d..cdeaafde10 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Taiko.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { var drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset; - drawableTaikoRuleset.LockPlayfieldMaxAspect.Value = false; + drawableTaikoRuleset.LockPlayfieldAspectRange.Value = false; var playfield = (TaikoPlayfield)drawableRuleset.Playfield; playfield.ClassicHitTargetPosition.Value = true; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.cs index 84aa5e6bba..f442435d9c 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDaycore.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModDaycore : ModDaycore { - public override double ScoreMultiplier => 0.3; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs index 89581c57bd..e517439ba4 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDoubleTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModDoubleTime : ModDoubleTime { - public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.cs index 68d6305fbf..9ef6fe8649 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHalfTime.cs @@ -7,6 +7,5 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModHalfTime : ModHalfTime { - public override double ScoreMultiplier => 0.3; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs index 7cb14635ff..ad5da3d601 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs @@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModNightcore : ModNightcore { - public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index f695c505a4..1b5d641612 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -118,6 +118,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public override bool RemoveWhenNotAlive => false; } + + // Most osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource). + public override IEnumerable GetSamples() => Enumerable.Empty(); } public abstract partial class DrawableTaikoHitObject : DrawableTaikoHitObject @@ -157,9 +160,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Content.Add(MainPiece = CreateMainPiece()); } - // Most osu!taiko hitsounds are managed by the drum (see DrumSampleMapping). - public override IEnumerable GetSamples() => Enumerable.Empty(); - protected abstract SkinnableDrawable CreateMainPiece(); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index 3325eda7cf..2f4a98bd8f 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -5,6 +5,7 @@ using osu.Game.Rulesets.Objects.Types; using System.Threading; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; @@ -15,7 +16,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Objects { - public class DrumRoll : TaikoStrongableHitObject, IHasPath + public class DrumRoll : TaikoStrongableHitObject, IHasPath, IHasSliderVelocity { /// /// Drum roll distance that results in a duration of 1 speed-adjusted beat length. @@ -35,6 +36,19 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public double Velocity { get; private set; } + public BindableNumber SliderVelocityBindable { get; } = new BindableDouble(1) + { + Precision = 0.01, + MinValue = 0.1, + MaxValue = 10 + }; + + public double SliderVelocity + { + get => SliderVelocityBindable.Value; + set => SliderVelocityBindable.Value = value; + } + /// /// Numer of ticks per beat length. /// @@ -52,7 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Objects TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); - double scoringDistance = base_distance * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; + double scoringDistance = base_distance * difficulty.SliderMultiplier * SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; tickSpacing = timingPoint.BeatLength / TickRate; @@ -81,7 +95,8 @@ namespace osu.Game.Rulesets.Taiko.Objects FirstTick = first, TickSpacing = tickSpacing, StartTime = t, - IsStrong = IsStrong + IsStrong = IsStrong, + Samples = Samples }); first = false; @@ -92,12 +107,21 @@ namespace osu.Game.Rulesets.Taiko.Objects protected override HitWindows CreateHitWindows() => HitWindows.Empty; - protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit(this) + { + StartTime = startTime, + Samples = Samples + }; public class StrongNestedHit : StrongNestedHitObject { // The strong hit of the drum roll doesn't actually provide any score. public override Judgement CreateJudgement() => new IgnoreJudgement(); + + public StrongNestedHit(TaikoHitObject parent) + : base(parent) + { + } } #region LegacyBeatmapEncoder diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs index 6bcb8674e6..206e8ecb5a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs @@ -33,10 +33,18 @@ namespace osu.Game.Rulesets.Taiko.Objects public override double MaximumJudgementOffset => HitWindow; - protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit(this) + { + StartTime = startTime, + Samples = Samples + }; public class StrongNestedHit : StrongNestedHitObject { + public StrongNestedHit(TaikoHitObject parent) + : base(parent) + { + } } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index 787079bfee..ec23079ed9 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -63,7 +63,7 @@ namespace osu.Game.Rulesets.Taiko.Objects if (isRimType != rimSamples.Any()) { if (isRimType) - Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); + Samples.Add(CreateHitSampleInfo(HitSampleInfo.HIT_CLAP)); else { foreach (var sample in rimSamples) @@ -72,10 +72,18 @@ namespace osu.Game.Rulesets.Taiko.Objects } } - protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit(this) + { + StartTime = startTime, + Samples = Samples + }; public class StrongNestedHit : StrongNestedHitObject { + public StrongNestedHit(TaikoHitObject parent) + : base(parent) + { + } } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs index 628c41d878..316115f44d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs @@ -15,6 +15,13 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public abstract class StrongNestedHitObject : TaikoHitObject { + public readonly TaikoHitObject Parent; + + protected StrongNestedHitObject(TaikoHitObject parent) + { + Parent = parent; + } + public override Judgement CreateJudgement() => new TaikoStrongJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game.Rulesets.Taiko/Objects/Swell.cs b/osu.Game.Rulesets.Taiko/Objects/Swell.cs index cb91c46b4d..9ad783ba7e 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Swell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Swell.cs @@ -33,7 +33,10 @@ namespace osu.Game.Rulesets.Taiko.Objects for (int i = 0; i < RequiredHits; i++) { cancellationToken.ThrowIfCancellationRequested(); - AddNested(new SwellTick()); + AddNested(new SwellTick + { + Samples = Samples + }); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs index d4d59d5d44..479ad8369a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Taiko.Objects if (IsStrongBindable.Value != strongSamples.Any()) { if (IsStrongBindable.Value) - Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); + Samples.Add(CreateHitSampleInfo(HitSampleInfo.HIT_FINISH)); else { foreach (var sample in strongSamples) diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 4b60ee3ccb..a77e6db6f3 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -1,23 +1,44 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Scoring { - internal partial class TaikoScoreProcessor : ScoreProcessor + public partial class TaikoScoreProcessor : ScoreProcessor { + private const double combo_base = 4; + public TaikoScoreProcessor() : base(new TaikoRuleset()) { } - protected override double DefaultAccuracyPortion => 0.75; + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 250000 * comboProgress + + 750000 * Math.Pow(Accuracy.Value, 3.6) * accuracyProgress + + bonusPortion; + } - protected override double DefaultComboPortion => 0.25; + protected override double GetBonusScoreChange(JudgementResult result) => base.GetBonusScoreChange(result) * strongScaleValue(result); - protected override double ClassicScoreMultiplier => 22; + protected override double GetComboScoreChange(JudgementResult result) + { + return Judgement.ToNumericResult(result.Type) + * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(400, combo_base)) + * strongScaleValue(result); + } + + private double strongScaleValue(JudgementResult result) + { + if (result.HitObject is StrongNestedHitObject strong) + return strong.Parent is DrumRollTick ? 3 : 7; + + return 1; + } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index a35fdb890d..d6824109b3 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -229,45 +229,27 @@ namespace osu.Game.Rulesets.Taiko return base.GetDisplayNameForHitResult(result); } - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { var timedHitEvents = score.HitEvents.Where(e => e.HitObject is Hit).ToList(); return new[] { - new StatisticRow + new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }), + new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) { - Columns = new[] - { - new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] { - Columns = new[] - { - new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] - { - new AverageHitError(timedHitEvents), - new UnstableRate(timedHitEvents) - }), true) - } - } + new AverageHitError(timedHitEvents), + new UnstableRate(timedHitEvents) + }), true) }; } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index a08877e2dd..64d406a308 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.UI { public new BindableDouble TimeRange => base.TimeRange; - public readonly BindableBool LockPlayfieldMaxAspect = new BindableBool(true); + public readonly BindableBool LockPlayfieldAspectRange = new BindableBool(true); public new TaikoInputManager KeyBindingInputManager => (TaikoInputManager)base.KeyBindingInputManager; @@ -69,7 +69,9 @@ namespace osu.Game.Rulesets.Taiko.UI const float scroll_rate = 10; // Since the time range will depend on a positional value, it is referenced to the x480 pixel space. - float ratio = DrawHeight / 480; + // Width is used because it defines how many notes fit on the playfield. + // We clamp the ratio to the maximum aspect ratio to keep scroll speed consistent on widths lower than the default. + float ratio = Math.Max(DrawSize.X / 768f, TaikoPlayfieldAdjustmentContainer.MAXIMUM_ASPECT); TimeRange.Value = (Playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate; } @@ -92,7 +94,7 @@ namespace osu.Game.Rulesets.Taiko.UI public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new TaikoPlayfieldAdjustmentContainer { - LockPlayfieldMaxAspect = { BindTarget = LockPlayfieldMaxAspect } + LockPlayfieldAspectRange = { BindTarget = LockPlayfieldAspectRange } }; protected override PassThroughInputManager CreateInputManager() => new TaikoInputManager(Ruleset.RulesetInfo); diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs index 4809791af8..92f2b74568 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Game.Audio; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.UI; @@ -17,12 +18,12 @@ namespace osu.Game.Rulesets.Taiko.UI public void Play(HitType hitType) { - var hitObject = GetMostValidObject(); + var hitSample = GetMostValidObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); - if (hitObject == null) + if (hitSample == null) return; - PlaySamples(new ISampleInfo[] { hitObject.SampleControlPoint.GetSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL) }); + PlaySamples(new ISampleInfo[] { new HitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL, hitSample.Bank, volume: hitSample.Volume) }); } public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead"); diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs index 42732d90e4..3587783104 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs @@ -11,9 +11,11 @@ namespace osu.Game.Rulesets.Taiko.UI public partial class TaikoPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768; - private const float default_aspect = 16f / 9f; - public readonly IBindable LockPlayfieldMaxAspect = new BindableBool(true); + public const float MAXIMUM_ASPECT = 16f / 9f; + public const float MINIMUM_ASPECT = 5f / 4f; + + public readonly IBindable LockPlayfieldAspectRange = new BindableBool(true); protected override void Update() { @@ -26,12 +28,22 @@ namespace osu.Game.Rulesets.Taiko.UI // // As a middle-ground, the aspect ratio can still be adjusted in the downwards direction but has a maximum limit. // This is still a bit weird, because readability changes with window size, but it is what it is. - if (LockPlayfieldMaxAspect.Value && Parent.ChildSize.X / Parent.ChildSize.Y > default_aspect) - height *= Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; + if (LockPlayfieldAspectRange.Value) + { + float currentAspect = Parent.ChildSize.X / Parent.ChildSize.Y; + if (currentAspect > MAXIMUM_ASPECT) + height *= currentAspect / MAXIMUM_ASPECT; + else if (currentAspect < MINIMUM_ASPECT) + height *= currentAspect / MINIMUM_ASPECT; + } + + // Limit the maximum relative height of the playfield to one-third of available area to avoid it masking out on extreme resolutions. + height = Math.Min(height, 1f / 3f); Height = height; - // Position the taiko playfield exactly one playfield from the top of the screen. + // Position the taiko playfield exactly one playfield from the top of the screen, if there is enough space for it. + // Note that the relative height cannot exceed one-third - if that limit is hit, the playfield will be exactly centered. RelativePositionAxes = Axes.Y; Y = height; } diff --git a/osu.Game.Tests.iOS/AppDelegate.cs b/osu.Game.Tests.iOS/AppDelegate.cs deleted file mode 100644 index b13027459f..0000000000 --- a/osu.Game.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using Foundation; -using osu.Framework.iOS; - -namespace osu.Game.Tests.iOS -{ - [Register("AppDelegate")] - public class AppDelegate : GameAppDelegate - { - protected override Framework.Game CreateGame() => new OsuTestBrowser(); - } -} diff --git a/osu.Game.Tests.iOS/Application.cs b/osu.Game.Tests.iOS/Application.cs index 4678be4fb8..e5df79f3de 100644 --- a/osu.Game.Tests.iOS/Application.cs +++ b/osu.Game.Tests.iOS/Application.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. -#nullable disable - -using UIKit; +using osu.Framework.iOS; namespace osu.Game.Tests.iOS { @@ -11,7 +9,7 @@ namespace osu.Game.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, null, typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 85d304da9c..970b6aaf60 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.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 System.IO; using System.Linq; using NUnit.Framework; @@ -161,6 +160,51 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestDecodeVideoWithLowercaseExtension() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("video-with-lowercase-extension.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var beatmap = decoder.Decode(stream); + var metadata = beatmap.Metadata; + + Assert.AreEqual("BG.jpg", metadata.BackgroundFile); + } + } + + [Test] + public void TestDecodeVideoWithUppercaseExtension() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("video-with-uppercase-extension.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var beatmap = decoder.Decode(stream); + var metadata = beatmap.Metadata; + + Assert.AreEqual("BG.jpg", metadata.BackgroundFile); + } + } + + [Test] + public void TestDecodeImageSpecifiedAsVideo() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("image-specified-as-video.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var beatmap = decoder.Decode(stream); + var metadata = beatmap.Metadata; + + Assert.AreEqual("BG.jpg", metadata.BackgroundFile); + } + } + [Test] public void TestDecodeBeatmapTimingPoints() { @@ -209,17 +253,17 @@ namespace osu.Game.Tests.Beatmaps.Formats var soundPoint = controlPoints.SamplePointAt(0); Assert.AreEqual(956, soundPoint.Time); - Assert.AreEqual("soft", soundPoint.SampleBank); + Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); Assert.AreEqual(60, soundPoint.SampleVolume); soundPoint = controlPoints.SamplePointAt(53373); Assert.AreEqual(53373, soundPoint.Time); - Assert.AreEqual("soft", soundPoint.SampleBank); + Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); Assert.AreEqual(60, soundPoint.SampleVolume); soundPoint = controlPoints.SamplePointAt(119637); Assert.AreEqual(119637, soundPoint.Time); - Assert.AreEqual("soft", soundPoint.SampleBank); + Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); Assert.AreEqual(80, soundPoint.SampleVolume); var effectPoint = controlPoints.EffectPointAt(0); @@ -261,10 +305,10 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(controlPoints.EffectPointAt(2500).KiaiMode, Is.False); Assert.That(controlPoints.EffectPointAt(3500).KiaiMode, Is.True); - Assert.That(controlPoints.SamplePointAt(500).SampleBank, Is.EqualTo("drum")); - Assert.That(controlPoints.SamplePointAt(1500).SampleBank, Is.EqualTo("drum")); - Assert.That(controlPoints.SamplePointAt(2500).SampleBank, Is.EqualTo("normal")); - Assert.That(controlPoints.SamplePointAt(3500).SampleBank, Is.EqualTo("drum")); + Assert.That(controlPoints.SamplePointAt(500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM)); + Assert.That(controlPoints.SamplePointAt(1500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM)); + Assert.That(controlPoints.SamplePointAt(2500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_NORMAL)); + Assert.That(controlPoints.SamplePointAt(3500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM)); Assert.That(controlPoints.TimingPointAt(500).BeatLength, Is.EqualTo(500).Within(0.1)); Assert.That(controlPoints.TimingPointAt(1500).BeatLength, Is.EqualTo(500).Within(0.1)); @@ -320,6 +364,8 @@ namespace osu.Game.Tests.Beatmaps.Formats { var comboColors = decoder.Decode(stream).ComboColours; + Debug.Assert(comboColors != null); + Color4[] expectedColors = { new Color4(142, 199, 255, 255), @@ -330,7 +376,7 @@ namespace osu.Game.Tests.Beatmaps.Formats new Color4(255, 177, 140, 255), new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored. }; - Assert.AreEqual(expectedColors.Length, comboColors?.Count); + Assert.AreEqual(expectedColors.Length, comboColors.Count); for (int i = 0; i < expectedColors.Length; i++) Assert.AreEqual(expectedColors[i], comboColors[i]); } @@ -415,14 +461,14 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.IsNotNull(positionData); Assert.IsNotNull(curveData); - Assert.AreEqual(new Vector2(192, 168), positionData.Position); + Assert.AreEqual(new Vector2(192, 168), positionData!.Position); Assert.AreEqual(956, hitObjects[0].StartTime); Assert.IsTrue(hitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); positionData = hitObjects[1] as IHasPosition; Assert.IsNotNull(positionData); - Assert.AreEqual(new Vector2(304, 56), positionData.Position); + Assert.AreEqual(new Vector2(304, 56), positionData!.Position); Assert.AreEqual(1285, hitObjects[1].StartTime); Assert.IsTrue(hitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); } @@ -464,7 +510,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual("Gameplay/soft-hitnormal8", getTestableSampleInfo(hitObjects[4]).LookupNames.First()); } - static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); + static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0]; } [Test] @@ -482,7 +528,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual("Gameplay/normal-hitnormal3", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); } - static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); + static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0]; } [Test] @@ -502,7 +548,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual(70, getTestableSampleInfo(hitObjects[3]).Volume); } - static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); + static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0]; } [Test] @@ -578,8 +624,8 @@ namespace osu.Game.Tests.Beatmaps.Formats [Test] public void TestFallbackDecoderForCorruptedHeader() { - Decoder decoder = null; - Beatmap beatmap = null; + Decoder decoder = null!; + Beatmap beatmap = null!; using (var resStream = TestResources.OpenResource("corrupted-header.osu")) using (var stream = new LineBufferedReader(resStream)) @@ -596,8 +642,8 @@ namespace osu.Game.Tests.Beatmaps.Formats [Test] public void TestFallbackDecoderForMissingHeader() { - Decoder decoder = null; - Beatmap beatmap = null; + Decoder decoder = null!; + Beatmap beatmap = null!; using (var resStream = TestResources.OpenResource("missing-header.osu")) using (var stream = new LineBufferedReader(resStream)) @@ -614,8 +660,8 @@ namespace osu.Game.Tests.Beatmaps.Formats [Test] public void TestDecodeFileWithEmptyLinesAtStart() { - Decoder decoder = null; - Beatmap beatmap = null; + Decoder decoder = null!; + Beatmap beatmap = null!; using (var resStream = TestResources.OpenResource("empty-lines-at-start.osu")) using (var stream = new LineBufferedReader(resStream)) @@ -632,8 +678,8 @@ namespace osu.Game.Tests.Beatmaps.Formats [Test] public void TestDecodeFileWithEmptyLinesAndNoHeader() { - Decoder decoder = null; - Beatmap beatmap = null; + Decoder decoder = null!; + Beatmap beatmap = null!; using (var resStream = TestResources.OpenResource("empty-line-instead-of-header.osu")) using (var stream = new LineBufferedReader(resStream)) @@ -650,8 +696,8 @@ namespace osu.Game.Tests.Beatmaps.Formats [Test] public void TestDecodeFileWithContentImmediatelyAfterHeader() { - Decoder decoder = null; - Beatmap beatmap = null; + Decoder decoder = null!; + Beatmap beatmap = null!; using (var resStream = TestResources.OpenResource("no-empty-line-after-header.osu")) using (var stream = new LineBufferedReader(resStream)) @@ -678,7 +724,7 @@ namespace osu.Game.Tests.Beatmaps.Formats [Test] public void TestAllowFallbackDecoderOverwrite() { - Decoder decoder = null; + Decoder decoder = null!; using (var resStream = TestResources.OpenResource("corrupted-header.osu")) using (var stream = new LineBufferedReader(resStream)) diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index 281ea4e4ff..34ff8bfd84 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.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.Linq; using NUnit.Framework; using osuTK; @@ -30,35 +28,35 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.IsTrue(storyboard.HasDrawable); Assert.AreEqual(6, storyboard.Layers.Count()); - StoryboardLayer background = storyboard.Layers.FirstOrDefault(l => l.Depth == 3); + StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); Assert.IsNotNull(background); Assert.AreEqual(16, background.Elements.Count); Assert.IsTrue(background.VisibleWhenFailing); Assert.IsTrue(background.VisibleWhenPassing); Assert.AreEqual("Background", background.Name); - StoryboardLayer fail = storyboard.Layers.FirstOrDefault(l => l.Depth == 2); + StoryboardLayer fail = storyboard.Layers.Single(l => l.Depth == 2); Assert.IsNotNull(fail); Assert.AreEqual(0, fail.Elements.Count); Assert.IsTrue(fail.VisibleWhenFailing); Assert.IsFalse(fail.VisibleWhenPassing); Assert.AreEqual("Fail", fail.Name); - StoryboardLayer pass = storyboard.Layers.FirstOrDefault(l => l.Depth == 1); + StoryboardLayer pass = storyboard.Layers.Single(l => l.Depth == 1); Assert.IsNotNull(pass); Assert.AreEqual(0, pass.Elements.Count); Assert.IsFalse(pass.VisibleWhenFailing); Assert.IsTrue(pass.VisibleWhenPassing); Assert.AreEqual("Pass", pass.Name); - StoryboardLayer foreground = storyboard.Layers.FirstOrDefault(l => l.Depth == 0); + StoryboardLayer foreground = storyboard.Layers.Single(l => l.Depth == 0); Assert.IsNotNull(foreground); Assert.AreEqual(151, foreground.Elements.Count); Assert.IsTrue(foreground.VisibleWhenFailing); Assert.IsTrue(foreground.VisibleWhenPassing); Assert.AreEqual("Foreground", foreground.Name); - StoryboardLayer overlay = storyboard.Layers.FirstOrDefault(l => l.Depth == int.MinValue); + StoryboardLayer overlay = storyboard.Layers.Single(l => l.Depth == int.MinValue); Assert.IsNotNull(overlay); Assert.IsEmpty(overlay.Elements); Assert.IsTrue(overlay.VisibleWhenFailing); @@ -76,7 +74,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var sprite = background.Elements.ElementAt(0) as StoryboardSprite; Assert.NotNull(sprite); - Assert.IsTrue(sprite.HasCommands); + Assert.IsTrue(sprite!.HasCommands); Assert.AreEqual(new Vector2(320, 240), sprite.InitialPosition); Assert.IsTrue(sprite.IsDrawable); Assert.AreEqual(Anchor.Centre, sprite.Origin); @@ -97,6 +95,27 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestLoopWithoutExplicitFadeOut() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("animation-loop-no-explicit-end-time.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); + Assert.AreEqual(1, background.Elements.Count); + + Assert.AreEqual(2000, background.Elements[0].StartTime); + Assert.AreEqual(2000, (background.Elements[0] as StoryboardAnimation)?.EarliestTransformTime); + + Assert.AreEqual(3000, (background.Elements[0] as StoryboardAnimation)?.GetEndTime()); + Assert.AreEqual(12000, (background.Elements[0] as StoryboardAnimation)?.EndTimeForDisplay); + } + } + [Test] public void TestCorrectAnimationStartTime() { @@ -171,6 +190,55 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestDecodeVideoWithLowercaseExtension() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("video-with-lowercase-extension.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer video = storyboard.Layers.Single(l => l.Name == "Video"); + Assert.That(video.Elements.Count, Is.EqualTo(1)); + + Assert.AreEqual("Video.avi", ((StoryboardVideo)video.Elements[0]).Path); + } + } + + [Test] + public void TestDecodeVideoWithUppercaseExtension() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("video-with-uppercase-extension.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer video = storyboard.Layers.Single(l => l.Name == "Video"); + Assert.That(video.Elements.Count, Is.EqualTo(1)); + + Assert.AreEqual("Video.AVI", ((StoryboardVideo)video.Elements[0]).Path); + } + } + + [Test] + public void TestDecodeImageSpecifiedAsVideo() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("image-specified-as-video.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer video = storyboard.Layers.Single(l => l.Name == "Video"); + Assert.That(video.Elements.Count, Is.Zero); + } + } + [Test] public void TestDecodeOutOfRangeLoopAnimationType() { diff --git a/osu.Game.Tests/Database/LegacyExporterTest.cs b/osu.Game.Tests/Database/LegacyModelExporterTest.cs similarity index 67% rename from osu.Game.Tests/Database/LegacyExporterTest.cs rename to osu.Game.Tests/Database/LegacyModelExporterTest.cs index d41b3a5017..0c4b0cc9c4 100644 --- a/osu.Game.Tests/Database/LegacyExporterTest.cs +++ b/osu.Game.Tests/Database/LegacyModelExporterTest.cs @@ -1,19 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Overlays.Notifications; +using Realms; namespace osu.Game.Tests.Database { [TestFixture] - public class LegacyExporterTest + public class LegacyModelExporterTest { - private TestLegacyExporter legacyExporter = null!; + private TestLegacyModelExporter legacyExporter = null!; private TemporaryNativeStorage storage = null!; private const string short_filename = "normal file name"; @@ -25,15 +32,15 @@ namespace osu.Game.Tests.Database public void SetUp() { storage = new TemporaryNativeStorage("export-storage"); - legacyExporter = new TestLegacyExporter(storage); + legacyExporter = new TestLegacyModelExporter(storage); } [Test] public void ExportFileWithNormalNameTest() { - var item = new TestPathInfo(short_filename); + var item = new TestModel(short_filename); - Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + Assert.That(item.Filename.Length, Is.LessThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH)); exportItemAndAssert(item, short_filename); } @@ -41,9 +48,9 @@ namespace osu.Game.Tests.Database [Test] public void ExportFileWithNormalNameMultipleTimesTest() { - var item = new TestPathInfo(short_filename); + var item = new TestModel(short_filename); - Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + Assert.That(item.Filename.Length, Is.LessThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH)); //Export multiple times for (int i = 0; i < 100; i++) @@ -56,24 +63,24 @@ namespace osu.Game.Tests.Database [Test] public void ExportFileWithSuperLongNameTest() { - int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); + int expectedLength = TestLegacyModelExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); string expectedName = long_filename.Remove(expectedLength); - var item = new TestPathInfo(long_filename); + var item = new TestModel(long_filename); - Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH)); exportItemAndAssert(item, expectedName); } [Test] public void ExportFileWithSuperLongNameMultipleTimesTest() { - int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); + int expectedLength = TestLegacyModelExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); string expectedName = long_filename.Remove(expectedLength); - var item = new TestPathInfo(long_filename); + var item = new TestModel(long_filename); - Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH)); //Export multiple times for (int i = 0; i < 100; i++) @@ -83,9 +90,12 @@ namespace osu.Game.Tests.Database } } - private void exportItemAndAssert(IHasNamedFiles item, string expectedName) + private void exportItemAndAssert(TestModel item, string expectedName) { - Assert.DoesNotThrow(() => legacyExporter.Export(item)); + Assert.DoesNotThrow(() => + { + Task.Run(() => legacyExporter.ExportAsync(new RealmLiveUnmanaged(item))).WaitSafely(); + }); Assert.That(storage.Exists($"exports/{expectedName}{legacyExporter.GetExtension()}"), Is.True); } @@ -96,30 +106,36 @@ namespace osu.Game.Tests.Database storage.Dispose(); } - private class TestPathInfo : IHasNamedFiles + private class TestLegacyModelExporter : LegacyExporter { - public string Filename { get; } - - public IEnumerable Files { get; } = new List(); - - public TestPathInfo(string filename) - { - Filename = filename; - } - - public override string ToString() => Filename; - } - - private class TestLegacyExporter : LegacyExporter - { - public TestLegacyExporter(Storage storage) + public TestLegacyModelExporter(Storage storage) : base(storage) { } public string GetExtension() => FileExtension; + public override void ExportToStream(TestModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default) + { + } + protected override string FileExtension => ".test"; } + + private class TestModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey + { + public Guid ID => Guid.Empty; + + public string Filename { get; } + + public IEnumerable Files { get; } = new List(); + + public TestModel(string filename) + { + Filename = filename; + } + + public override string ToString() => Filename; + } } } diff --git a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs index 1e1c214c30..5a3ef619d1 100644 --- a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs @@ -37,45 +37,6 @@ namespace osu.Game.Tests.Editing.Checks cpi.Add(2000, new SampleControlPoint { SampleVolume = volume_muted }); } - [Test] - public void TestNormalControlPointVolume() - { - var hitCircle = new HitCircle - { - StartTime = 0, - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } - }; - hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - - assertOk(new List { hitCircle }); - } - - [Test] - public void TestLowControlPointVolume() - { - var hitCircle = new HitCircle - { - StartTime = 1000, - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } - }; - hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - - assertLowVolume(new List { hitCircle }); - } - - [Test] - public void TestMutedControlPointVolume() - { - var hitCircle = new HitCircle - { - StartTime = 2000, - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } - }; - hitCircle.ApplyDefaults(cpi, new BeatmapDifficulty()); - - assertMuted(new List { hitCircle }); - } - [Test] public void TestNormalSampleVolume() { @@ -122,7 +83,7 @@ namespace osu.Game.Tests.Editing.Checks var sliderHead = new SliderHeadCircle { StartTime = 0, - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } }; sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); @@ -135,7 +96,7 @@ namespace osu.Game.Tests.Editing.Checks var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500) { - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } }; slider.ApplyDefaults(cpi, new BeatmapDifficulty()); @@ -155,13 +116,13 @@ namespace osu.Game.Tests.Editing.Checks var sliderTick = new SliderTick { StartTime = 250, - Samples = new List { new HitSampleInfo("slidertick") } + Samples = new List { new HitSampleInfo("slidertick", volume: volume_regular) } }; sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500) { - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } // Applies to the tail. + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } // Applies to the tail. }; slider.ApplyDefaults(cpi, new BeatmapDifficulty()); @@ -174,14 +135,14 @@ namespace osu.Game.Tests.Editing.Checks var sliderHead = new SliderHeadCircle { StartTime = 0, - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } }; sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); var sliderTick = new SliderTick { StartTime = 250, - Samples = new List { new HitSampleInfo("slidertick") } + Samples = new List { new HitSampleInfo("slidertick", volume: volume_regular) } }; sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); @@ -194,59 +155,6 @@ namespace osu.Game.Tests.Editing.Checks assertMutedPassive(new List { slider }); } - [Test] - public void TestMutedControlPointVolumeSliderHead() - { - var sliderHead = new SliderHeadCircle - { - StartTime = 2000, - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } - }; - sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); - - var sliderTick = new SliderTick - { - StartTime = 2250, - Samples = new List { new HitSampleInfo("slidertick") } - }; - sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); - - var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 2000, endTime: 2500) - { - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } - }; - slider.ApplyDefaults(cpi, new BeatmapDifficulty()); - - assertMuted(new List { slider }); - } - - [Test] - public void TestMutedControlPointVolumeSliderTail() - { - var sliderHead = new SliderHeadCircle - { - StartTime = 0, - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } - }; - sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); - - var sliderTick = new SliderTick - { - StartTime = 250, - Samples = new List { new HitSampleInfo("slidertick") } - }; - sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); - - // Ends after the 5% control point. - var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 2500) - { - Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } - }; - slider.ApplyDefaults(cpi, new BeatmapDifficulty()); - - assertMutedPassive(new List { slider }); - } - private void assertOk(List hitObjects) { Assert.That(check.Run(getContext(hitObjects)), Is.Empty); diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index f556f6e2fe..6399507aa0 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; using osu.Game.Tests.Visual; @@ -74,12 +75,9 @@ namespace osu.Game.Tests.Editing [TestCase(2)] public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier) { - assertSnapDistance(100, new HitObject + assertSnapDistance(100, new Slider { - DifficultyControlPoint = new DifficultyControlPoint - { - SliderVelocity = multiplier - } + SliderVelocity = multiplier }, false); } @@ -87,12 +85,9 @@ namespace osu.Game.Tests.Editing [TestCase(2)] public void TestSpeedMultiplierDoesChangeDistanceSnap(float multiplier) { - assertSnapDistance(100 * multiplier, new HitObject + assertSnapDistance(100 * multiplier, new Slider { - DifficultyControlPoint = new DifficultyControlPoint - { - SliderVelocity = multiplier - } + SliderVelocity = multiplier }, true); } @@ -114,12 +109,9 @@ namespace osu.Game.Tests.Editing const float base_distance = 100; const float slider_velocity = 1.2f; - var referenceObject = new HitObject + var referenceObject = new Slider { - DifficultyControlPoint = new DifficultyControlPoint - { - SliderVelocity = slider_velocity - } + SliderVelocity = slider_velocity }; assertSnapDistance(base_distance * slider_velocity, referenceObject, true); diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index 90c7688443..a261185473 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -76,22 +76,38 @@ namespace osu.Game.Tests.Gameplay // Reset with a miss instead. scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { - Header = new FrameHeader(0, 0, 0, new Dictionary { { HitResult.Miss, 1 } }, DateTimeOffset.Now) + Header = new FrameHeader(0, 0, 0, 0, new Dictionary { { HitResult.Miss, 1 } }, new ScoreProcessorStatistics + { + MaximumBaseScore = 300, + BaseScore = 0, + AccuracyJudgementCount = 1, + ComboPortion = 0, + BonusPortion = 0 + }, DateTimeOffset.Now) }); Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); + Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo(0)); // Reset with no judged hit. scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { - Header = new FrameHeader(0, 0, 0, new Dictionary(), DateTimeOffset.Now) + Header = new FrameHeader(0, 0, 0, 0, new Dictionary(), new ScoreProcessorStatistics + { + MaximumBaseScore = 0, + BaseScore = 0, + AccuracyJudgementCount = 0, + ComboPortion = 0, + BonusPortion = 0 + }, DateTimeOffset.Now) }); Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); Assert.That(scoreProcessor.JudgedHits, Is.Zero); Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); + Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo(1)); } [Test] diff --git a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs index cd6879cf01..a03b29f7bc 100644 --- a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs +++ b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs @@ -49,5 +49,31 @@ namespace osu.Game.Tests.Mods Assert.That(mod3, Is.EqualTo(mod2)); Assert.That(doubleConvertedMod3, Is.EqualTo(doubleConvertedMod2)); } + + [Test] + public void TestModWithMultipleSettings() + { + var ruleset = new OsuRuleset(); + + var mod1 = new OsuModDifficultyAdjust { OverallDifficulty = { Value = 10 }, CircleSize = { Value = 0 } }; + var mod2 = new OsuModDifficultyAdjust { OverallDifficulty = { Value = 10 }, CircleSize = { Value = 6 } }; + var mod3 = new OsuModDifficultyAdjust { OverallDifficulty = { Value = 10 }, CircleSize = { Value = 6 } }; + + var doubleConvertedMod1 = new APIMod(mod1).ToMod(ruleset); + var doubleConvertedMod2 = new APIMod(mod2).ToMod(ruleset); + var doubleConvertedMod3 = new APIMod(mod3).ToMod(ruleset); + + Assert.That(mod1, Is.Not.EqualTo(mod2)); + Assert.That(doubleConvertedMod1, Is.Not.EqualTo(doubleConvertedMod2)); + + Assert.That(mod2, Is.EqualTo(mod2)); + Assert.That(doubleConvertedMod2, Is.EqualTo(doubleConvertedMod2)); + + Assert.That(mod2, Is.EqualTo(mod3)); + Assert.That(doubleConvertedMod2, Is.EqualTo(doubleConvertedMod3)); + + Assert.That(mod3, Is.EqualTo(mod2)); + Assert.That(doubleConvertedMod3, Is.EqualTo(doubleConvertedMod2)); + } } } diff --git a/osu.Game.Tests/Mods/ModSettingsTest.cs b/osu.Game.Tests/Mods/ModSettingsTest.cs index b9ea1f2567..5ec9629dc2 100644 --- a/osu.Game.Tests/Mods/ModSettingsTest.cs +++ b/osu.Game.Tests/Mods/ModSettingsTest.cs @@ -2,6 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -10,7 +13,7 @@ namespace osu.Game.Tests.Mods public class ModSettingsTest { [Test] - public void TestModSettingsUnboundWhenCopied() + public void TestModSettingsUnboundWhenCloned() { var original = new OsuModDoubleTime(); var copy = (OsuModDoubleTime)original.DeepClone(); @@ -22,7 +25,7 @@ namespace osu.Game.Tests.Mods } [Test] - public void TestMultiModSettingsUnboundWhenCopied() + public void TestMultiModSettingsUnboundWhenCloned() { var original = new MultiMod(new OsuModDoubleTime()); var copy = (MultiMod)original.DeepClone(); @@ -32,5 +35,67 @@ namespace osu.Game.Tests.Mods Assert.That(((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value, Is.EqualTo(2.0)); Assert.That(((OsuModDoubleTime)copy.Mods[0]).SpeedChange.Value, Is.EqualTo(1.5)); } + + [Test] + public void TestDifferentTypeSettingsKeptWhenCopied() + { + const double setting_change = 50.4; + + var modDouble = new TestNonMatchingSettingTypeModDouble { TestSetting = { Value = setting_change } }; + var modBool = new TestNonMatchingSettingTypeModBool { TestSetting = { Default = false, Value = true } }; + var modInt = new TestNonMatchingSettingTypeModInt { TestSetting = { Value = (int)setting_change / 2 } }; + + modDouble.CopyCommonSettingsFrom(modBool); + modDouble.CopyCommonSettingsFrom(modInt); + modBool.CopyCommonSettingsFrom(modDouble); + modBool.CopyCommonSettingsFrom(modInt); + modInt.CopyCommonSettingsFrom(modDouble); + modInt.CopyCommonSettingsFrom(modBool); + + Assert.That(modDouble.TestSetting.Value, Is.EqualTo(setting_change)); + Assert.That(modBool.TestSetting.Value, Is.EqualTo(true)); + Assert.That(modInt.TestSetting.Value, Is.EqualTo((int)setting_change / 2)); + } + + [Test] + public void TestDefaultValueKeptWhenCopied() + { + var modBoolTrue = new TestNonMatchingSettingTypeModBool { TestSetting = { Default = true, Value = false } }; + var modBoolFalse = new TestNonMatchingSettingTypeModBool { TestSetting = { Default = false, Value = true } }; + + modBoolFalse.CopyCommonSettingsFrom(modBoolTrue); + + Assert.That(modBoolFalse.TestSetting.Default, Is.EqualTo(false)); + Assert.That(modBoolFalse.TestSetting.Value, Is.EqualTo(modBoolTrue.TestSetting.Value)); + } + + private class TestNonMatchingSettingTypeModDouble : TestNonMatchingSettingTypeMod + { + public override string Acronym => "NMD"; + public override BindableNumber TestSetting { get; } = new BindableDouble(); + } + + private class TestNonMatchingSettingTypeModInt : TestNonMatchingSettingTypeMod + { + public override string Acronym => "NMI"; + public override BindableNumber TestSetting { get; } = new BindableInt(); + } + + private class TestNonMatchingSettingTypeModBool : TestNonMatchingSettingTypeMod + { + public override string Acronym => "NMB"; + public override Bindable TestSetting { get; } = new BindableBool(); + } + + private abstract class TestNonMatchingSettingTypeMod : Mod + { + public override string Name => "Non-matching setting type mod"; + public override LocalisableString Description => "Description"; + public override double ScoreMultiplier => 1; + public override ModType Type => ModType.Conversion; + + [SettingSource("Test setting")] + public abstract IBindable TestSetting { get; } + } } } diff --git a/osu.Game.Tests/Resources/Shaders/sh_TestFragment.fs b/osu.Game.Tests/Resources/Shaders/sh_TestFragment.fs index c70ad751be..99b85d0502 100644 --- a/osu.Game.Tests/Resources/Shaders/sh_TestFragment.fs +++ b/osu.Game.Tests/Resources/Shaders/sh_TestFragment.fs @@ -1,11 +1,14 @@ -#include "sh_Utils.h" +#define HIGH_PRECISION_VERTEX -varying mediump vec2 v_TexCoord; -varying mediump vec4 v_TexRect; +#include "sh_Utils.h" +#include "sh_Masking.h" + +layout(location = 2) in highp vec2 v_TexCoord; + +layout(location = 0) out vec4 o_Colour; void main(void) { - float hueValue = v_TexCoord.x / (v_TexRect[2] - v_TexRect[0]); - gl_FragColor = hsv2rgb(vec4(hueValue, 1, 1, 1)); + highp float hueValue = v_TexCoord.x / (v_TexRect[2] - v_TexRect[0]); + o_Colour = getRoundedColor(hsv2rgb(vec4(hueValue, 1, 1, 1)), v_TexCoord); } - diff --git a/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs b/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs index 4485356fa4..505554bb33 100644 --- a/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs +++ b/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs @@ -1,31 +1,25 @@ -#include "sh_Utils.h" +layout(location = 0) in highp vec2 m_Position; +layout(location = 1) in lowp vec4 m_Colour; +layout(location = 2) in highp vec2 m_TexCoord; +layout(location = 3) in highp vec4 m_TexRect; +layout(location = 4) in mediump vec2 m_BlendRange; -attribute highp vec2 m_Position; -attribute lowp vec4 m_Colour; -attribute mediump vec2 m_TexCoord; -attribute mediump vec4 m_TexRect; -attribute mediump vec2 m_BlendRange; - -varying highp vec2 v_MaskingPosition; -varying lowp vec4 v_Colour; -varying mediump vec2 v_TexCoord; -varying mediump vec4 v_TexRect; -varying mediump vec2 v_BlendRange; - -uniform highp mat4 g_ProjMatrix; -uniform highp mat3 g_ToMaskingSpace; +layout(location = 0) out highp vec2 v_MaskingPosition; +layout(location = 1) out lowp vec4 v_Colour; +layout(location = 2) out highp vec2 v_TexCoord; +layout(location = 3) out highp vec4 v_TexRect; +layout(location = 4) out mediump vec2 v_BlendRange; void main(void) { - // Transform from screen space to masking space. - highp vec3 maskingPos = g_ToMaskingSpace * vec3(m_Position, 1.0); - v_MaskingPosition = maskingPos.xy / maskingPos.z; + // Transform from screen space to masking space. + highp vec3 maskingPos = g_ToMaskingSpace * vec3(m_Position, 1.0); + v_MaskingPosition = maskingPos.xy / maskingPos.z; - v_Colour = m_Colour; - v_TexCoord = m_TexCoord; - v_TexRect = m_TexRect; - v_BlendRange = m_BlendRange; + v_Colour = m_Colour; + v_TexCoord = m_TexCoord; + v_TexRect = m_TexRect; + v_BlendRange = m_BlendRange; - gl_Position = gProjMatrix * vec4(m_Position, 1.0, 1.0); + gl_Position = g_ProjMatrix * vec4(m_Position, 1.0, 1.0); } - diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index a2d81c0a75..a77dc8d49b 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -179,7 +179,7 @@ namespace osu.Game.Tests.Resources BeatmapHash = beatmap.Hash, Ruleset = beatmap.Ruleset, Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }, - TotalScore = 2845370, + TotalScore = 284537, Accuracy = 0.95, MaxCombo = 999, Position = 1, diff --git a/osu.Game.Tests/Resources/animation-loop-no-explicit-end-time.osb b/osu.Game.Tests/Resources/animation-loop-no-explicit-end-time.osb new file mode 100644 index 0000000000..7afaa445df --- /dev/null +++ b/osu.Game.Tests/Resources/animation-loop-no-explicit-end-time.osb @@ -0,0 +1,6 @@ +[Events] +//Storyboard Layer 0 (Background) +Animation,Background,Centre,"img.jpg",320,240,2,150,LoopForever + F,0,2000,,0,1 + L,2000,10 + F,18,0,1000,1,0 diff --git a/osu.Game.Tests/Resources/image-specified-as-video.osb b/osu.Game.Tests/Resources/image-specified-as-video.osb new file mode 100644 index 0000000000..9cea7dd4e7 --- /dev/null +++ b/osu.Game.Tests/Resources/image-specified-as-video.osb @@ -0,0 +1,4 @@ +osu file format v14 + +[Events] +Video,0,"BG.jpg",0,0 diff --git a/osu.Game.Tests/Resources/storyboard_only_video.osu b/osu.Game.Tests/Resources/storyboard_only_video.osu new file mode 100644 index 0000000000..25f1ff6361 --- /dev/null +++ b/osu.Game.Tests/Resources/storyboard_only_video.osu @@ -0,0 +1,31 @@ +osu file format v14 + +[Events] +//Background and Video events +0,0,"BG.jpg",0,0 +Video,0,"video.avi" +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +1674,333.333333333333,4,2,1,70,1,0 +1674,-100,4,2,1,70,0,0 +3340,-100,4,2,1,70,0,0 +3507,-100,4,2,1,70,0,0 +3673,-100,4,2,1,70,0,0 + +[Colours] +Combo1 : 240,80,80 +Combo2 : 171,252,203 +Combo3 : 128,128,255 +Combo4 : 249,254,186 + +[HitObjects] +148,303,1674,5,6,3:2:0:0: +378,252,1840,1,0,0:0:0:0: +389,270,2340,5,2,0:1:0:0: diff --git a/osu.Game.Tests/Resources/video-with-lowercase-extension.osb b/osu.Game.Tests/Resources/video-with-lowercase-extension.osb new file mode 100644 index 0000000000..eec09722ed --- /dev/null +++ b/osu.Game.Tests/Resources/video-with-lowercase-extension.osb @@ -0,0 +1,5 @@ +osu file format v14 + +[Events] +0,0,"BG.jpg",0,0 +Video,0,"Video.avi",0,0 diff --git a/osu.Game.Tests/Resources/video-with-uppercase-extension.osb b/osu.Game.Tests/Resources/video-with-uppercase-extension.osb new file mode 100644 index 0000000000..3834a547f2 --- /dev/null +++ b/osu.Game.Tests/Resources/video-with-uppercase-extension.osb @@ -0,0 +1,5 @@ +osu file format v14 + +[Events] +0,0,"BG.jpg",0,0 +Video,0,"Video.AVI",0,0 diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 826c610f56..e5e96d2033 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -14,11 +14,12 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; -using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Rulesets.Scoring @@ -31,7 +32,7 @@ namespace osu.Game.Tests.Rulesets.Scoring [SetUp] public void SetUp() { - scoreProcessor = new ScoreProcessor(new TestRuleset()); + scoreProcessor = new ScoreProcessor(new OsuRuleset()); beatmap = new TestBeatmap(new RulesetInfo()) { HitObjects = new List @@ -41,15 +42,14 @@ namespace osu.Game.Tests.Rulesets.Scoring }; } - [TestCase(ScoringMode.Standardised, HitResult.Meh, 750_000)] - [TestCase(ScoringMode.Standardised, HitResult.Ok, 800_000)] + [TestCase(ScoringMode.Standardised, HitResult.Meh, 116_667)] + [TestCase(ScoringMode.Standardised, HitResult.Ok, 233_338)] [TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)] - [TestCase(ScoringMode.Classic, HitResult.Meh, 20)] - [TestCase(ScoringMode.Classic, HitResult.Ok, 23)] + [TestCase(ScoringMode.Classic, HitResult.Meh, 0)] + [TestCase(ScoringMode.Classic, HitResult.Ok, 2)] [TestCase(ScoringMode.Classic, HitResult.Great, 36)] public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore) { - scoreProcessor.Mode.Value = scoringMode; scoreProcessor.ApplyBeatmap(beatmap); var judgementResult = new JudgementResult(beatmap.HitObjects.Single(), new OsuJudgement()) @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Rulesets.Scoring }; scoreProcessor.ApplyResult(judgementResult); - Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); + Assert.That(scoreProcessor.GetDisplayScore(scoringMode), Is.EqualTo(expectedScore).Within(0.5d)); } /// @@ -70,39 +70,29 @@ namespace osu.Game.Tests.Rulesets.Scoring /// Expected score after all objects have been judged, rounded to the nearest integer. /// /// This test intentionally misses the 3rd hitobject to achieve lower than 75% accuracy and 50% max combo. - /// - /// For standardised scoring, is calculated using the following formula: - /// 1_000_000 * (((3 * ) / (4 * )) * 30% + (bestCombo / maxCombo) * 70%) - /// - /// - /// For classic scoring, is calculated using the following formula: - /// / * 936 - /// where 936 is simplified from: - /// 75% * 4 * 300 * (1 + 1/25) - /// /// - [TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)] // (3 * 0) / (4 * 300) * 300_000 + (0 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 387_500)] // (3 * 50) / (4 * 300) * 300_000 + (2 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 425_000)] // (3 * 100) / (4 * 300) * 300_000 + (2 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 492_857)] // (3 * 200) / (4 * 350) * 300_000 + (2 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 575_000)] // (3 * 300) / (4 * 300) * 300_000 + (2 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 575_000)] // (3 * 350) / (4 * 350) * 300_000 + (2 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 700_000)] // (3 * 0) / (4 * 10) * 300_000 + 700_000 (max combo 0) - [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 925_000)] // (3 * 10) / (4 * 10) * 300_000 + 700_000 (max combo 0) - [TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (3 * 0) / (4 * 30) * 300_000 + (0 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 575_000)] // (3 * 30) / (4 * 30) * 300_000 + (0 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) - [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) + [TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)] + [TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 79_333)] + [TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 158_667)] + [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 302_402)] + [TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 492_894)] + [TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 492_894)] + [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] + [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 541_894)] + [TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] + [TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 492_894)] + [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] + [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] - [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 86)] - [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 104)] - [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 140)] - [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 190)] - [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 190)] - [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 18)] - [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 31)] + [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 4)] + [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15)] + [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 53)] + [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 140)] + [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 140)] + [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 11)] [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] - [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 12)] + [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 9)] [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 36)] [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 36)] public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) @@ -113,59 +103,18 @@ namespace osu.Game.Tests.Rulesets.Scoring { HitObjects = new List(Enumerable.Repeat(new TestHitObject(maxResult), 4)) }; - scoreProcessor.Mode.Value = scoringMode; scoreProcessor.ApplyBeatmap(fourObjectBeatmap); for (int i = 0; i < 4; i++) { - var judgementResult = new JudgementResult(fourObjectBeatmap.HitObjects[i], new Judgement()) + var judgementResult = new JudgementResult(fourObjectBeatmap.HitObjects[i], new TestJudgement(maxResult)) { Type = i == 2 ? minResult : hitResult }; scoreProcessor.ApplyResult(judgementResult); } - Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); - } - - /// - /// This test uses a beatmap with four small ticks and one object with the of . - /// Its goal is to ensure that with the of , - /// small ticks contribute to the accuracy portion, but not the combo portion. - /// In contrast, does not have separate combo and accuracy portion (they are multiplied by each other). - /// - [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, 978_571)] // (3 * 10 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, 914_286)] // (3 * 0 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 - [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, 34)] - [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, 30)] - public void TestSmallTicksAccuracy(ScoringMode scoringMode, HitResult hitResult, int expectedScore) - { - IEnumerable hitObjects = Enumerable - .Repeat(new TestHitObject(HitResult.SmallTickHit), 4) - .Append(new TestHitObject(HitResult.Ok)); - IBeatmap fiveObjectBeatmap = new TestBeatmap(new RulesetInfo()) - { - HitObjects = hitObjects.ToList() - }; - scoreProcessor.Mode.Value = scoringMode; - scoreProcessor.ApplyBeatmap(fiveObjectBeatmap); - - for (int i = 0; i < 4; i++) - { - var judgementResult = new JudgementResult(fiveObjectBeatmap.HitObjects[i], new Judgement()) - { - Type = i == 2 ? HitResult.SmallTickMiss : hitResult - }; - scoreProcessor.ApplyResult(judgementResult); - } - - var lastJudgementResult = new JudgementResult(fiveObjectBeatmap.HitObjects.Last(), new Judgement()) - { - Type = HitResult.Ok - }; - scoreProcessor.ApplyResult(lastJudgementResult); - - Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); + Assert.That(scoreProcessor.GetDisplayScore(scoringMode), Is.EqualTo(expectedScore).Within(0.5d)); } [Test] @@ -173,10 +122,9 @@ namespace osu.Game.Tests.Rulesets.Scoring [Values(ScoringMode.Standardised, ScoringMode.Classic)] ScoringMode scoringMode) { - scoreProcessor.Mode.Value = scoringMode; scoreProcessor.ApplyBeatmap(new TestBeatmap(new RulesetInfo())); - Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); + Assert.That(scoreProcessor.GetDisplayScore(scoringMode), Is.Zero); } [TestCase(HitResult.IgnoreHit, HitResult.IgnoreMiss)] @@ -294,28 +242,6 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.AreEqual(expectedReturnValue, hitResult.IsScorable()); } - [TestCase(HitResult.Perfect, 1_000_000)] - [TestCase(HitResult.SmallTickHit, 1_000_000)] - [TestCase(HitResult.LargeTickHit, 1_000_000)] - [TestCase(HitResult.SmallBonus, 1_000_000 + Judgement.SMALL_BONUS_SCORE)] - [TestCase(HitResult.LargeBonus, 1_000_000 + Judgement.LARGE_BONUS_SCORE)] - public void TestGetScoreWithExternalStatistics(HitResult result, int expectedScore) - { - var statistic = new Dictionary { { result, 1 } }; - - scoreProcessor.ApplyBeatmap(new Beatmap - { - HitObjects = { new TestHitObject(result) } - }); - - Assert.That(scoreProcessor.ComputeScore(ScoringMode.Standardised, new ScoreInfo - { - Ruleset = new TestRuleset().RulesetInfo, - MaxCombo = result.AffectsCombo() ? 1 : 0, - Statistics = statistic - }), Is.EqualTo(expectedScore).Within(0.5d)); - } - #pragma warning disable CS0618 [Test] public void TestLegacyComboIncrease() @@ -330,29 +256,6 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.That(HitResult.LegacyComboIncrease.IsHit(), Is.True); Assert.That(HitResult.LegacyComboIncrease.IsScorable(), Is.True); Assert.That(HitResultExtensions.ALL_TYPES, Does.Not.Contain(HitResult.LegacyComboIncrease)); - - // Cannot be used to apply results. - Assert.Throws(() => scoreProcessor.ApplyBeatmap(new Beatmap - { - HitObjects = { new TestHitObject(HitResult.LegacyComboIncrease) } - })); - - ScoreInfo testScore = new ScoreInfo - { - MaxCombo = 1, - Statistics = new Dictionary - { - { HitResult.Great, 1 } - }, - MaximumStatistics = new Dictionary - { - { HitResult.Great, 1 }, - { HitResult.LegacyComboIncrease, 1 } - } - }; - - double totalScore = new TestScoreProcessor().ComputeScore(ScoringMode.Standardised, testScore); - Assert.That(totalScore, Is.EqualTo(750_000)); // 500K from accuracy (100%), and 250K from combo (50%). } #pragma warning restore CS0618 @@ -362,36 +265,30 @@ namespace osu.Game.Tests.Rulesets.Scoring const int count_judgements = 1000; const int count_misses = 1; - double actual = new TestScoreProcessor().ComputeAccuracy(new ScoreInfo + beatmap = new TestBeatmap(new RulesetInfo()) { - Statistics = new Dictionary + HitObjects = new List(Enumerable.Repeat(new TestHitObject(HitResult.Great), count_judgements)) + }; + + scoreProcessor = new TestScoreProcessor(); + scoreProcessor.ApplyBeatmap(beatmap); + + for (int i = 0; i < beatmap.HitObjects.Count; i++) + { + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[i], new TestJudgement(HitResult.Great)) { - { HitResult.Great, count_judgements - count_misses }, - { HitResult.Miss, count_misses } - } - }); + Type = i == 0 ? HitResult.Miss : HitResult.Great + }); + } const double expected = (count_judgements - count_misses) / (double)count_judgements; + double actual = scoreProcessor.Accuracy.Value; Assert.That(actual, Is.Not.EqualTo(0.0)); Assert.That(actual, Is.Not.EqualTo(1.0)); Assert.That(actual, Is.EqualTo(expected).Within(Precision.FLOAT_EPSILON)); } - private class TestRuleset : Ruleset - { - public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); - - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); - - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); - - public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); - - public override string Description => string.Empty; - public override string ShortName => string.Empty; - } - private class TestJudgement : Judgement { public override HitResult MaxResult { get; } @@ -419,14 +316,18 @@ namespace osu.Game.Tests.Rulesets.Scoring private partial class TestScoreProcessor : ScoreProcessor { - protected override double DefaultAccuracyPortion => 0.5; - protected override double DefaultComboPortion => 0.5; - public TestScoreProcessor() : base(new TestRuleset()) { } + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 500000 * comboProgress + + 500000 * Accuracy.Value * accuracyProgress + + bonusPortion; + } + // ReSharper disable once MemberHidesStaticFromOuterClass private class TestRuleset : Ruleset { diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index f8248e88bb..6639b6dd68 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -164,7 +164,7 @@ namespace osu.Game.Tests.Rulesets this.parentManager = parentManager; } - public override byte[] LoadRaw(string name) => parentManager.LoadRaw(name); + public override byte[] GetRawData(string fileName) => parentManager.GetRawData(fileName); public bool IsDisposed { get; private set; } diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index 81ebc59729..0c25934d52 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -10,7 +10,6 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Extensions; @@ -120,10 +119,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "custom.osk")); assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu); - import1.PerformRead(s => - { - new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); - }); + await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(import1, exportStream); string exportFilename = import1.GetDisplayString(); @@ -141,10 +137,7 @@ namespace osu.Game.Tests.Skins.IO var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 『1』", "author 1"), "custom.osk")); assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", osu); - import1.PerformRead(s => - { - new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); - }); + await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(import1, exportStream); string exportFilename = import1.GetDisplayString().GetValidFilename(); @@ -208,7 +201,7 @@ namespace osu.Game.Tests.Skins.IO }); [Test] - public Task TestExportThenImportDefaultSkin() => runSkinTest(osu => + public Task TestExportThenImportDefaultSkin() => runSkinTest(async osu => { var skinManager = osu.Dependencies.Get(); @@ -218,30 +211,28 @@ namespace osu.Game.Tests.Skins.IO Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; - skinManager.CurrentSkinInfo.Value.PerformRead(s => + await skinManager.CurrentSkinInfo.Value.PerformRead(async s => { Assert.IsFalse(s.Protected); Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); - new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); + await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream); Assert.Greater(exportStream.Length, 0); }); - var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); + var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk")); - imported.GetResultSafely().PerformRead(s => + imported.PerformRead(s => { Assert.IsFalse(s.Protected); Assert.AreNotEqual(originalSkinId, s.ID); Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); }); - - return Task.CompletedTask; }); [Test] - public Task TestExportThenImportClassicSkin() => runSkinTest(osu => + public Task TestExportThenImportClassicSkin() => runSkinTest(async osu => { var skinManager = osu.Dependencies.Get(); @@ -253,26 +244,24 @@ namespace osu.Game.Tests.Skins.IO Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; - skinManager.CurrentSkinInfo.Value.PerformRead(s => + await skinManager.CurrentSkinInfo.Value.PerformRead(async s => { Assert.IsFalse(s.Protected); Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); - new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); + await new LegacySkinExporter(osu.Dependencies.Get()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream); Assert.Greater(exportStream.Length, 0); }); - var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); + var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk")); - imported.GetResultSafely().PerformRead(s => + imported.PerformRead(s => { Assert.IsFalse(s.Protected); Assert.AreNotEqual(originalSkinId, s.ID); Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); }); - - return Task.CompletedTask; }); #endregion diff --git a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs index f1533a32b9..f0a9ce7beb 100644 --- a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs +++ b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.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 NUnit.Framework; @@ -51,9 +49,13 @@ namespace osu.Game.Tests.Testing [Test] public void TestRetrieveShader() { - AddAssert("ruleset shaders retrieved", () => - Dependencies.Get().LoadRaw(@"sh_TestVertex.vs") != null && - Dependencies.Get().LoadRaw(@"sh_TestFragment.fs") != null); + AddStep("ruleset shaders retrieved without error", () => + { + Dependencies.Get().GetRawData(@"sh_TestVertex.vs"); + Dependencies.Get().GetRawData(@"sh_TestFragment.fs"); + Dependencies.Get().Load(@"TestVertex", @"TestFragment"); + Dependencies.Get().Load(VertexShaderDescriptor.TEXTURE_2, @"TestFragment"); + }); } [Test] @@ -76,12 +78,12 @@ namespace osu.Game.Tests.Testing } public override IResourceStore CreateResourceStore() => new NamespacedResourceStore(TestResources.GetStore(), @"Resources"); - public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new TestRulesetConfigManager(); + public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new TestRulesetConfigManager(); public override IEnumerable GetModsFor(ModType type) => Array.Empty(); - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => null; - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null; - public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null; + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => null!; + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null!; + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null!; } private class TestRulesetConfigManager : IRulesetConfigManager diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs index fbdaad1cd8..8f4250799e 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -311,6 +311,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsDrawable => true; public double StartTime => double.MinValue; public double EndTime => double.MaxValue; + public double EndTimeForDisplay => double.MaxValue; public Drawable CreateDrawable() => new DrawableTestStoryboardElement(); } diff --git a/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs b/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs index 07427c242f..711d9ab5ea 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs @@ -100,8 +100,10 @@ namespace osu.Game.Tests.Visual.Background private IUniformBuffer? borderDataBuffer; - public override void Draw(IRenderer renderer) + protected override void BindUniformResources(IShader shader, IRenderer renderer) { + base.BindUniformResources(shader, renderer); + borderDataBuffer ??= renderer.CreateUniformBuffer(); borderDataBuffer.Data = borderDataBuffer.Data with { @@ -109,9 +111,7 @@ namespace osu.Game.Tests.Visual.Background TexelSize = texelSize }; - TextureShader.BindUniformBlock("m_BorderData", borderDataBuffer); - - base.Draw(renderer); + shader.BindUniformBlock("m_BorderData", borderDataBuffer); } protected override bool CanDrawOpaqueInterior => false; diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 1e9982f8d4..cfa45ec6ef 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -264,8 +264,9 @@ namespace osu.Game.Tests.Visual.Collections assertCollectionName(1, "First"); } - [Test] - public void TestCollectionRenamedOnTextChange() + [TestCase(false)] + [TestCase(true)] + public void TestCollectionRenamedOnTextChange(bool commitWithEnter) { BeatmapCollection first = null!; DrawableCollectionListItem firstItem = null!; @@ -293,9 +294,19 @@ namespace osu.Game.Tests.Visual.Collections AddStep("change first collection name", () => { firstItem.ChildrenOfType().First().Text = "First"; - InputManager.Key(Key.Enter); }); + if (commitWithEnter) + AddStep("commit via enter", () => InputManager.Key(Key.Enter)); + else + { + AddStep("commit via click away", () => + { + InputManager.MoveMouseTo(firstItem.ScreenSpaceDrawQuad.TopLeft - new Vector2(10)); + InputManager.Click(MouseButton.Left); + }); + } + AddUntilStep("collection has new name", () => first.Name == "First"); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs index 56b16301be..353acfa4ba 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs @@ -42,7 +42,8 @@ namespace osu.Game.Tests.Visual.Editing { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(90, 90) + Size = new Vector2(90, 90), + Scale = new Vector2(3), } }; }); @@ -64,17 +65,24 @@ namespace osu.Game.Tests.Visual.Editing InputManager.MoveMouseTo(tickMarkerHead.ScreenSpaceDrawQuad.Centre); InputManager.PressButton(MouseButton.Left); }); - AddStep("move to 8 and release", () => + AddStep("move to 1", () => InputManager.MoveMouseTo(getPositionForDivisor(1))); + AddStep("move to 16 and release", () => { - InputManager.MoveMouseTo(tickSliderBar.ScreenSpaceDrawQuad.Centre); + InputManager.MoveMouseTo(getPositionForDivisor(16)); InputManager.ReleaseButton(MouseButton.Left); }); - AddAssert("divisor is 8", () => bindableBeatDivisor.Value == 8); + AddAssert("divisor is 16", () => bindableBeatDivisor.Value == 16); AddStep("hold marker", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move to 16", () => InputManager.MoveMouseTo(getPositionForDivisor(16))); - AddStep("move to ~10 and release", () => + AddStep("move to ~6 and release", () => + { + InputManager.MoveMouseTo(getPositionForDivisor(6)); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("divisor clamped to 8", () => bindableBeatDivisor.Value == 8); + AddStep("move to ~10 and click", () => { InputManager.MoveMouseTo(getPositionForDivisor(10)); + InputManager.PressButton(MouseButton.Left); InputManager.ReleaseButton(MouseButton.Left); }); AddAssert("divisor clamped to 8", () => bindableBeatDivisor.Value == 8); @@ -82,12 +90,11 @@ namespace osu.Game.Tests.Visual.Editing private Vector2 getPositionForDivisor(int divisor) { - float relativePosition = (float)Math.Clamp(divisor, 0, 16) / 16; - var sliderDrawQuad = tickSliderBar.ScreenSpaceDrawQuad; - return new Vector2( - sliderDrawQuad.TopLeft.X + sliderDrawQuad.Width * relativePosition, - sliderDrawQuad.Centre.Y - ); + float localX = (1 - 1 / (float)divisor) * tickSliderBar.UsableWidth + tickSliderBar.RangePadding; + return tickSliderBar.ToScreenSpace(new Vector2( + localX, + tickSliderBar.DrawHeight / 2 + )); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index 21b925a257..70e4420a45 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -187,7 +187,7 @@ namespace osu.Game.Tests.Visual.Editing private class SnapProvider : IDistanceSnapProvider { - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.Grids) => new SnapResult(screenSpacePosition, 0); + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.AllGrids) => new SnapResult(screenSpacePosition, 0); public Bindable DistanceSpacingMultiplier { get; } = new BindableDouble(1); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index d26bb6bb8a..c4c05278b5 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -6,7 +6,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; @@ -95,10 +94,6 @@ namespace osu.Game.Tests.Visual.Editing var path = slider.Path; return path.ControlPoints.Count == 2 && path.ControlPoints.SequenceEqual(addedObject.Path.ControlPoints); }); - - // see `HitObject.control_point_leniency`. - AddAssert("sample control point has correct time", () => Precision.AlmostEquals(slider.SampleControlPoint.Time, slider.GetEndTime(), 1)); - AddAssert("difficulty control point has correct time", () => slider.DifficultyControlPoint.Time == slider.StartTime); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs deleted file mode 100644 index 5914290d40..0000000000 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.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. - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Extensions; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Rulesets.Mania; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.GameplayTest; -using osu.Game.Screens.Select; -using osu.Game.Tests.Resources; - -namespace osu.Game.Tests.Visual.Editing -{ - public partial class TestSceneEditorNavigation : OsuGameTestScene - { - [Test] - public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() - { - BeatmapSetInfo beatmapSet = null!; - - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); - - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); - AddStep("switch ruleset", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); - - AddStep("open editor", () => ((PlaySongSelect)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); - AddStep("test gameplay", () => ((Editor)Game.ScreenStack.CurrentScreen).TestGameplay()); - - AddUntilStep("wait for player", () => - { - // notifications may fire at almost any inopportune time and cause annoying test failures. - // relentlessly attempt to dismiss any and all interfering overlays, which includes notifications. - // this is theoretically not foolproof, but it's the best that can be done here. - Game.CloseAllOverlays(); - return Game.ScreenStack.CurrentScreen is EditorPlayer editorPlayer && editorPlayer.IsLoaded; - }); - - AddAssert("current ruleset is osu!", () => Game.Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); - - AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); - AddAssert("previous ruleset restored", () => Game.Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); - } - } -} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index b396b382ff..64c48e74cf 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -122,19 +122,9 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Beatmap has correct timing point", () => EditorBeatmap.ControlPointInfo.TimingPoints.Single().Time == 500); - // After placement these must be non-default as defaults are read-only. - AddAssert("Placed object has non-default control points", () => - !ReferenceEquals(EditorBeatmap.HitObjects[0].SampleControlPoint, SampleControlPoint.DEFAULT) && - !ReferenceEquals(EditorBeatmap.HitObjects[0].DifficultyControlPoint, DifficultyControlPoint.DEFAULT)); - ReloadEditorToSameBeatmap(); AddAssert("Beatmap still has correct timing point", () => EditorBeatmap.ControlPointInfo.TimingPoints.Single().Time == 500); - - // After placement these must be non-default as defaults are read-only. - AddAssert("Placed object still has non-default control points", () => - !ReferenceEquals(EditorBeatmap.HitObjects[0].SampleControlPoint, SampleControlPoint.DEFAULT) && - !ReferenceEquals(EditorBeatmap.HitObjects[0].DifficultyControlPoint, DifficultyControlPoint.DEFAULT)); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index 7ab0188114..9bdb9a513c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -108,12 +108,16 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").TriggerClick()); + ExpandingToolboxContainer toolboxContainer = null!; + + AddStep("move mouse to toolbox", () => InputManager.MoveMouseTo(toolboxContainer = hitObjectComposer.ChildrenOfType().First())); + AddUntilStep("toolbox is expanded", () => toolboxContainer.Expanded.Value); + AddUntilStep("wait for toolbox to expand", () => toolboxContainer.LatestTransformEndTime, () => Is.EqualTo(Time.Current)); + AddStep("move mouse to overlapping toggle button", () => { var playfield = hitObjectComposer.Playfield.ScreenSpaceDrawQuad; - var button = hitObjectComposer - .ChildrenOfType().First() - .ChildrenOfType().First(b => playfield.Contains(b.ScreenSpaceDrawQuad.Centre)); + var button = toolboxContainer.ChildrenOfType().First(b => playfield.Contains(b.ScreenSpaceDrawQuad.Centre)); InputManager.MoveMouseTo(button); }); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs index ab82678eb9..c874b39028 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs @@ -8,10 +8,10 @@ using Humanizer; using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; @@ -61,10 +61,7 @@ namespace osu.Game.Tests.Visual.Editing new PathControlPoint(new Vector2(100, 0)) } }, - DifficultyControlPoint = new DifficultyControlPoint - { - SliderVelocity = 2 - } + SliderVelocity = 2 }); }); } @@ -95,13 +92,27 @@ namespace osu.Game.Tests.Visual.Editing hitObjectHasVelocity(1, 5); } + [Test] + public void TestUndo() + { + clickDifficultyPiece(1); + velocityPopoverHasSingleValue(2); + + setVelocityViaPopover(5); + hitObjectHasVelocity(1, 5); + dismissPopover(); + + AddStep("undo", () => Editor.Undo()); + hitObjectHasVelocity(1, 2); + } + [Test] public void TestMultipleSelectionWithSameSliderVelocity() { AddStep("unify slider velocity", () => { - foreach (var h in EditorBeatmap.HitObjects) - h.DifficultyControlPoint.SliderVelocity = 1.5; + foreach (var h in EditorBeatmap.HitObjects.OfType()) + h.SliderVelocity = 1.5; }); AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); @@ -185,7 +196,7 @@ namespace osu.Game.Tests.Visual.Editing private void hitObjectHasVelocity(int objectIndex, double velocity) => AddAssert($"{objectIndex.ToOrdinalWords()} has velocity {velocity}", () => { var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); - return h.DifficultyControlPoint.SliderVelocity == velocity; + return h is IHasSliderVelocity hasSliderVelocity && hasSliderVelocity.SliderVelocity == velocity; }); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs similarity index 54% rename from osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs rename to osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index e8dcc6f19b..b0b51a5dbd 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs @@ -4,14 +4,16 @@ #nullable disable using System.Linq; +using System.Collections.Generic; using Humanizer; using NUnit.Framework; using osu.Framework.Testing; +using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; @@ -23,7 +25,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public partial class TestSceneHitObjectSamplePointAdjustments : EditorTestScene + public partial class TestSceneHitObjectSampleAdjustments : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); @@ -39,10 +41,9 @@ namespace osu.Game.Tests.Visual.Editing { StartTime = 0, Position = (OsuPlayfield.BASE_SIZE - new Vector2(100, 0)) / 2, - SampleControlPoint = new SampleControlPoint + Samples = new List { - SampleBank = "normal", - SampleVolume = 80 + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: 80) } }); @@ -50,15 +51,34 @@ namespace osu.Game.Tests.Visual.Editing { StartTime = 500, Position = (OsuPlayfield.BASE_SIZE + new Vector2(100, 0)) / 2, - SampleControlPoint = new SampleControlPoint + Samples = new List { - SampleBank = "soft", - SampleVolume = 60 + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT, volume: 60) } }); }); } + [Test] + public void TestAddSampleAddition() + { + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("add clap addition", () => InputManager.Key(Key.R)); + + hitObjectHasSampleBank(0, "normal"); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + + AddStep("remove clap addition", () => InputManager.Key(Key.R)); + + hitObjectHasSampleBank(0, "normal"); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL); + } + [Test] public void TestPopoverHasFocus() { @@ -70,7 +90,7 @@ namespace osu.Game.Tests.Visual.Editing public void TestSingleSelection() { clickSamplePiece(0); - samplePopoverHasSingleBank("normal"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); samplePopoverHasSingleVolume(80); dismissPopover(); @@ -80,14 +100,29 @@ namespace osu.Game.Tests.Visual.Editing AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.First())); clickSamplePiece(1); - samplePopoverHasSingleBank("soft"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); samplePopoverHasSingleVolume(60); setVolumeViaPopover(90); hitObjectHasSampleVolume(1, 90); - setBankViaPopover("drum"); - hitObjectHasSampleBank(1, "drum"); + setBankViaPopover(HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + } + + [Test] + public void TestUndo() + { + clickSamplePiece(1); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); + samplePopoverHasSingleVolume(60); + + setVolumeViaPopover(90); + hitObjectHasSampleVolume(1, 90); + dismissPopover(); + + AddStep("undo", () => Editor.Undo()); + hitObjectHasSampleVolume(1, 60); } [Test] @@ -96,7 +131,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("unify sample volume", () => { foreach (var h in EditorBeatmap.HitObjects) - h.SampleControlPoint.SampleVolume = 50; + { + for (int i = 0; i < h.Samples.Count; i++) + { + h.Samples[i] = h.Samples[i].With(newVolume: 50); + } + } }); AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); @@ -131,36 +171,41 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestMultipleSelectionWithSameSampleBank() + public void TestPopoverMultipleSelectionWithSameSampleBank() { AddStep("unify sample bank", () => { foreach (var h in EditorBeatmap.HitObjects) - h.SampleControlPoint.SampleBank = "soft"; + { + for (int i = 0; i < h.Samples.Count; i++) + { + h.Samples[i] = h.Samples[i].With(newBank: HitSampleInfo.BANK_SOFT); + } + } }); AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); clickSamplePiece(0); - samplePopoverHasSingleBank("soft"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); dismissPopover(); clickSamplePiece(1); - samplePopoverHasSingleBank("soft"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); setBankViaPopover(string.Empty); - hitObjectHasSampleBank(0, "soft"); - hitObjectHasSampleBank(1, "soft"); - samplePopoverHasSingleBank("soft"); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); - setBankViaPopover("drum"); - hitObjectHasSampleBank(0, "drum"); - hitObjectHasSampleBank(1, "drum"); - samplePopoverHasSingleBank("drum"); + setBankViaPopover(HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + samplePopoverHasSingleBank(HitSampleInfo.BANK_DRUM); } [Test] - public void TestMultipleSelectionWithDifferentSampleBank() + public void TestPopoverMultipleSelectionWithDifferentSampleBank() { AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); clickSamplePiece(0); @@ -172,14 +217,109 @@ namespace osu.Game.Tests.Visual.Editing samplePopoverHasIndeterminateBank(); setBankViaPopover(string.Empty); - hitObjectHasSampleBank(0, "normal"); - hitObjectHasSampleBank(1, "soft"); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); samplePopoverHasIndeterminateBank(); - setBankViaPopover("normal"); - hitObjectHasSampleBank(0, "normal"); - hitObjectHasSampleBank(1, "normal"); - samplePopoverHasSingleBank("normal"); + setBankViaPopover(HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_NORMAL); + samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); + } + + [Test] + public void TestHotkeysMultipleSelectionWithSameSampleBank() + { + AddStep("unify sample bank", () => + { + foreach (var h in EditorBeatmap.HitObjects) + { + for (int i = 0; i < h.Samples.Count; i++) + { + h.Samples[i] = h.Samples[i].With(newBank: HitSampleInfo.BANK_SOFT); + } + } + }); + + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + + AddStep("Press normal bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_NORMAL); + + AddStep("Press drum bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + + AddStep("Press auto bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + // Should be a noop. + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + } + + [Test] + public void TestHotkeysDuringPlacement() + { + AddStep("Enter placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move mouse to centre", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre)); + + AddStep("Move between two objects", () => EditorClock.Seek(250)); + + AddStep("Press normal bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + checkPlacementSample(HitSampleInfo.BANK_NORMAL); + + AddStep("Press drum bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + checkPlacementSample(HitSampleInfo.BANK_DRUM); + + AddStep("Press auto bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + checkPlacementSample(HitSampleInfo.BANK_NORMAL); + + AddStep("Move after second object", () => EditorClock.Seek(750)); + checkPlacementSample(HitSampleInfo.BANK_SOFT); + + AddStep("Move to first object", () => EditorClock.Seek(0)); + checkPlacementSample(HitSampleInfo.BANK_NORMAL); + + void checkPlacementSample(string expected) => AddAssert($"Placement sample is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First().Bank, () => Is.EqualTo(expected)); } private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} sample piece", () => @@ -248,7 +388,7 @@ namespace osu.Game.Tests.Visual.Editing private void hitObjectHasSampleVolume(int objectIndex, int volume) => AddAssert($"{objectIndex.ToOrdinalWords()} has volume {volume}", () => { var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); - return h.SampleControlPoint.SampleVolume == volume; + return h.Samples.All(o => o.Volume == volume); }); private void setBankViaPopover(string bank) => AddStep($"set bank {bank} via popover", () => @@ -262,10 +402,16 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Key(Key.Enter); }); + private void hitObjectHasSamples(int objectIndex, params string[] samples) => AddAssert($"{objectIndex.ToOrdinalWords()} has samples {string.Join(',', samples)}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Select(s => s.Name).SequenceEqual(samples); + }); + private void hitObjectHasSampleBank(int objectIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} has bank {bank}", () => { var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); - return h.SampleControlPoint.SampleBank == bank; + return h.Samples.All(o => o.Bank == bank); }); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs new file mode 100644 index 0000000000..7f9a69833c --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Game.Database; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneLocallyModifyingOnlineBeatmaps : EditorSavingTestScene + { + public override void SetUpSteps() + { + CreateInitialBeatmap = () => + { + var importedSet = Game.BeatmapManager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).GetResultSafely(); + return Game.BeatmapManager.GetWorkingBeatmap(importedSet!.Value.Beatmaps.First()); + }; + + base.SetUpSteps(); + } + + [Test] + public void TestLocallyModifyingOnlineBeatmap() + { + AddAssert("editor beatmap has online ID", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.GreaterThan(0)); + + AddStep("delete first hitobject", () => EditorBeatmap.RemoveAt(0)); + SaveEditor(); + + ReloadEditorToSameBeatmap(); + AddAssert("editor beatmap online ID reset", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.EqualTo(-1)); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs new file mode 100644 index 0000000000..a5681bea4a --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.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.Linq; +using NUnit.Framework; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestScenePlacementBlueprint : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private GlobalActionContainer globalActionContainer => this.ChildrenOfType().Single(); + + [Test] + public void TestCommitPlacementViaGlobalAction() + { + Playfield playfield = null!; + + AddStep("select slider placement tool", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left of playfield", () => + { + playfield = this.ChildrenOfType().Single(); + var location = (3 * playfield.ScreenSpaceDrawQuad.TopLeft + playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("begin placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right of playfield", () => + { + var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("confirm via global action", () => + { + globalActionContainer.TriggerPressed(GlobalAction.Select); + globalActionContainer.TriggerReleased(GlobalAction.Select); + }); + AddAssert("slider placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); + } + + [Test] + public void TestAbortPlacementViaGlobalAction() + { + Playfield playfield = null!; + + AddStep("select slider placement tool", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left of playfield", () => + { + playfield = this.ChildrenOfType().Single(); + var location = (3 * playfield.ScreenSpaceDrawQuad.TopLeft + playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("begin placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right of playfield", () => + { + var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("abort via global action", () => + { + globalActionContainer.TriggerPressed(GlobalAction.Back); + globalActionContainer.TriggerReleased(GlobalAction.Back); + }); + AddAssert("editor is still current", () => Editor.IsCurrentScreen()); + AddAssert("slider not placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(0)); + AddAssert("no active placement", () => this.ChildrenOfType().Single().CurrentPlacement.PlacementActive, + () => Is.EqualTo(PlacementBlueprint.PlacementState.Waiting)); + } + + [Test] + public void TestCommitPlacementViaToolChange() + { + Playfield playfield = null!; + + AddStep("select slider placement tool", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left of playfield", () => + { + playfield = this.ChildrenOfType().Single(); + var location = (3 * playfield.ScreenSpaceDrawQuad.TopLeft + playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("begin placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right of playfield", () => + { + var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + + AddStep("change tool to circle", () => InputManager.Key(Key.Number2)); + AddAssert("slider placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs index 709d796e97..08e036248b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs @@ -77,5 +77,39 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("object has non-zero duration", () => EditorBeatmap.HitObjects.OfType().Single().Duration > 0); } + + [Test] + public void TestDisallowRepeatsOnZeroDurationObjects() + { + DragArea dragArea; + + AddStep("add zero length slider", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.Add(new Slider + { + Position = new Vector2(256, 256), + StartTime = 2700 + }); + }); + + AddStep("hold down drag bar", () => + { + // distinguishes between the actual drag bar and its "underlay shadow". + dragArea = this.ChildrenOfType().Single(bar => bar.HandlePositionalInput); + InputManager.MoveMouseTo(dragArea); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("try to extend drag bar", () => + { + var blueprint = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(blueprint.SelectionQuad.TopLeft + new Vector2(100, 0)); + }); + + AddStep("release button", () => InputManager.PressButton(MouseButton.Left)); + + AddAssert("object has zero repeats", () => EditorBeatmap.HitObjects.OfType().Single().RepeatCount == 0); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index 5442b3bfef..f3f942b74b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -35,14 +35,14 @@ namespace osu.Game.Tests.Visual.Gameplay var referenceBeatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); AddUntilStep("score above zero", () => Player.ScoreProcessor.TotalScore.Value > 0); - AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.Any(kc => kc.CountPresses > 2)); + AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Counters.Any(kc => kc.CountPresses.Value > 2)); seekTo(referenceBeatmap.Breaks[0].StartTime); - AddAssert("keys not counting", () => !Player.HUDOverlay.KeyCounter.IsCounting); + AddAssert("keys not counting", () => !Player.HUDOverlay.KeyCounter.IsCounting.Value); AddAssert("overlay displays 100% accuracy", () => Player.BreakOverlay.ChildrenOfType().Single().AccuracyDisplay.Current.Value == 1); AddStep("rewind", () => Player.GameplayClockContainer.Seek(-80000)); - AddUntilStep("key counter reset", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses == 0)); + AddUntilStep("key counter reset", () => Player.HUDOverlay.KeyCounter.Counters.All(kc => kc.CountPresses.Value == 0)); seekTo(referenceBeatmap.HitObjects[^1].GetEndTime()); AddUntilStep("results displayed", () => getResultsScreen()?.IsLoaded == true); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs index 1dffeed01b..751aeb4e13 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs @@ -31,11 +31,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); addSeekStep(3000); AddAssert("all judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => h.Judged)); - AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.Select(kc => kc.CountPresses).Sum() == 15); + AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Counters.Select(kc => kc.CountPresses.Value).Sum() == 15); AddStep("clear results", () => Player.Results.Clear()); addSeekStep(0); AddAssert("none judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => !h.Judged)); - AddUntilStep("key counters reset", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses == 0)); + AddUntilStep("key counters reset", () => Player.HUDOverlay.KeyCounter.Counters.All(kc => kc.CountPresses.Value == 0)); AddAssert("no results triggered", () => Player.Results.Count == 0); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs index 31133f00d9..e52ec6f8cc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -73,8 +73,7 @@ namespace osu.Game.Tests.Visual.Gameplay new HitCircle { StartTime = t += spacing, - Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, - SampleControlPoint = new SampleControlPoint { SampleBank = "soft" }, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) }, }, new HitCircle { @@ -84,8 +83,7 @@ namespace osu.Game.Tests.Visual.Gameplay { StartTime = t += spacing, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 }), - Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }, - SampleControlPoint = new SampleControlPoint { SampleBank = "soft" }, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) }, }, }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index b918c5e64a..f97019e466 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -31,8 +31,8 @@ namespace osu.Game.Tests.Visual.Gameplay private HUDOverlay hudOverlay = null!; - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor => gameplayState.ScoreProcessor; [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); @@ -45,7 +45,7 @@ namespace osu.Game.Tests.Visual.Gameplay // best way to check without exposing. private Drawable hideTarget => hudOverlay.KeyCounter; - private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); + private Drawable keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().Single(); [BackgroundDependencyLoader] private void load() @@ -267,7 +267,7 @@ namespace osu.Game.Tests.Visual.Gameplay hudOverlay = new HUDOverlay(null, Array.Empty()); // Add any key just to display the key counter visually. - hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); + hudOverlay.KeyCounter.Add(new KeyCounterKeyboardTrigger(Key.Space)); scoreProcessor.Combo.Value = 1; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs index 890ac21b40..22f7111f68 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs @@ -8,6 +8,8 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -17,43 +19,63 @@ namespace osu.Game.Tests.Visual.Gameplay { public TestSceneKeyCounter() { - KeyCounterKeyboard testCounter; - - KeyCounterDisplay kc = new KeyCounterDisplay + KeyCounterDisplay defaultDisplay = new DefaultKeyCounterDisplay { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Children = new KeyCounter[] - { - testCounter = new KeyCounterKeyboard(Key.X), - new KeyCounterKeyboard(Key.X), - new KeyCounterMouse(MouseButton.Left), - new KeyCounterMouse(MouseButton.Right), - }, + Position = new Vector2(0, 72.7f) }; + KeyCounterDisplay argonDisplay = new ArgonKeyCounterDisplay + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Position = new Vector2(0, -72.7f) + }; + + defaultDisplay.AddRange(new InputTrigger[] + { + new KeyCounterKeyboardTrigger(Key.X), + new KeyCounterKeyboardTrigger(Key.X), + new KeyCounterMouseTrigger(MouseButton.Left), + new KeyCounterMouseTrigger(MouseButton.Right), + }); + + argonDisplay.AddRange(new InputTrigger[] + { + new KeyCounterKeyboardTrigger(Key.X), + new KeyCounterKeyboardTrigger(Key.X), + new KeyCounterMouseTrigger(MouseButton.Left), + new KeyCounterMouseTrigger(MouseButton.Right), + }); + + var testCounter = (DefaultKeyCounter)defaultDisplay.Counters.First(); + AddStep("Add random", () => { Key key = (Key)((int)Key.A + RNG.Next(26)); - kc.Add(new KeyCounterKeyboard(key)); + defaultDisplay.Add(new KeyCounterKeyboardTrigger(key)); + argonDisplay.Add(new KeyCounterKeyboardTrigger(key)); }); - Key testKey = ((KeyCounterKeyboard)kc.Children.First()).Key; + Key testKey = ((KeyCounterKeyboardTrigger)defaultDisplay.Counters.First().Trigger).Key; - void addPressKeyStep() + addPressKeyStep(); + AddAssert($"Check {testKey} counter after keypress", () => testCounter.CountPresses.Value == 1); + addPressKeyStep(); + AddAssert($"Check {testKey} counter after keypress", () => testCounter.CountPresses.Value == 2); + AddStep("Disable counting", () => { - AddStep($"Press {testKey} key", () => InputManager.Key(testKey)); - } + argonDisplay.IsCounting.Value = false; + defaultDisplay.IsCounting.Value = false; + }); + addPressKeyStep(); + AddAssert($"Check {testKey} count has not changed", () => testCounter.CountPresses.Value == 2); - addPressKeyStep(); - AddAssert($"Check {testKey} counter after keypress", () => testCounter.CountPresses == 1); - addPressKeyStep(); - AddAssert($"Check {testKey} counter after keypress", () => testCounter.CountPresses == 2); - AddStep("Disable counting", () => testCounter.IsCounting = false); - addPressKeyStep(); - AddAssert($"Check {testKey} count has not changed", () => testCounter.CountPresses == 2); + Add(defaultDisplay); + Add(argonDisplay); - Add(kc); + void addPressKeyStep() => AddStep($"Press {testKey} key", () => InputManager.Key(testKey)); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 2ea27c2fef..dbd1ce1f6e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -45,6 +45,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private SessionStatics sessionStatics { get; set; } + [Resolved] + private OsuConfigManager config { get; set; } + [Cached(typeof(INotificationOverlay))] private readonly NotificationOverlay notificationOverlay; @@ -317,6 +320,7 @@ namespace osu.Game.Tests.Visual.Gameplay saveVolumes(); setFullVolume(); + AddStep("enable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, true)); AddStep("change epilepsy warning", () => epilepsyWarning = warning); AddStep("load dummy beatmap", () => resetPlayer(false)); @@ -333,12 +337,30 @@ namespace osu.Game.Tests.Visual.Gameplay restoreVolumes(); } + [Test] + public void TestEpilepsyWarningWithDisabledStoryboard() + { + saveVolumes(); + setFullVolume(); + + AddStep("disable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, false)); + AddStep("change epilepsy warning", () => epilepsyWarning = true); + AddStep("load dummy beatmap", () => resetPlayer(false)); + + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + + AddUntilStep("epilepsy warning absent", () => getWarning() == null); + + restoreVolumes(); + } + [Test] public void TestEpilepsyWarningEarlyExit() { saveVolumes(); setFullVolume(); + AddStep("enable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, true)); AddStep("set epilepsy warning", () => epilepsyWarning = true); AddStep("load dummy beatmap", () => resetPlayer(false)); @@ -449,7 +471,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("click notification", () => notification.TriggerClick()); } - private EpilepsyWarning getWarning() => loader.ChildrenOfType().SingleOrDefault(); + private EpilepsyWarning getWarning() => loader.ChildrenOfType().SingleOrDefault(w => w.IsAlive); private partial class TestPlayerLoader : PlayerLoader { diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index 0469df1de3..d16f51f36e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -164,6 +164,36 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("all results reverted", () => playfield.JudgedObjects.Count, () => Is.EqualTo(0)); } + [Test] + public void TestRevertNestedObjects() + { + ManualClock clock = null; + + var beatmap = new Beatmap(); + beatmap.HitObjects.Add(new TestHitObjectWithNested { Duration = 40 }); + + createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock())); + + AddStep("skip to middle of object", () => clock.CurrentTime = (beatmap.HitObjects[0].StartTime + beatmap.HitObjects[0].GetEndTime()) / 2); + AddAssert("2 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(2)); + + AddStep("skip to before end of object", () => clock.CurrentTime = beatmap.HitObjects[0].GetEndTime() - 1); + AddAssert("3 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(3)); + + DrawableHitObject drawableHitObject = null; + HashSet revertedHitObjects = new HashSet(); + + AddStep("retrieve drawable hit object", () => drawableHitObject = playfield.ChildrenOfType().Single()); + AddStep("set up revert tracking", () => + { + revertedHitObjects.Clear(); + drawableHitObject.OnRevertResult += (ho, _) => revertedHitObjects.Add(ho.HitObject); + }); + AddStep("skip back to object start", () => clock.CurrentTime = beatmap.HitObjects[0].StartTime); + AddAssert("3 reverts fired", () => revertedHitObjects, () => Has.Count.EqualTo(3)); + AddAssert("no objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(0)); + } + [Test] public void TestApplyHitResultOnKilled() { @@ -258,6 +288,8 @@ namespace osu.Game.Tests.Visual.Gameplay { RegisterPool(poolSize); RegisterPool(poolSize); + RegisterPool(poolSize); + RegisterPool(poolSize); } protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new TestHitObjectLifetimeEntry(hitObject); @@ -388,6 +420,120 @@ namespace osu.Game.Tests.Visual.Gameplay } } + private class TestHitObjectWithNested : TestHitObject + { + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + base.CreateNestedHitObjects(cancellationToken); + + for (int i = 0; i < 3; ++i) + AddNested(new NestedHitObject { StartTime = (float)Duration * (i + 1) / 4 }); + } + } + + private class NestedHitObject : ConvertHitObject + { + } + + private partial class DrawableTestHitObjectWithNested : DrawableHitObject + { + private Container nestedContainer; + + public DrawableTestHitObjectWithNested() + : base(null) + { + } + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Red + }, + nestedContainer = new Container + { + RelativeSizeAxes = Axes.Both + } + }); + } + + protected override void OnApply() + { + base.OnApply(); + + Size = new Vector2(200, 50); + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + nestedContainer.Add(hitObject); + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + nestedContainer.Clear(false); + } + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + base.CheckForResult(userTriggered, timeOffset); + if (timeOffset >= 0) + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } + } + + private partial class DrawableNestedHitObject : DrawableHitObject + { + public DrawableNestedHitObject() + : this(null) + { + } + + public DrawableNestedHitObject(NestedHitObject hitObject) + : base(hitObject) + { + Size = new Vector2(15); + Colour = Colour4.White; + RelativePositionAxes = Axes.Both; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(new Circle + { + RelativeSizeAxes = Axes.Both, + }); + } + + protected override void OnApply() + { + base.OnApply(); + + X = (float)((HitObject.StartTime - ParentHitObject!.HitObject.StartTime) / (ParentHitObject.HitObject.GetEndTime() - ParentHitObject.HitObject.StartTime)); + Y = 0.5f; + + LifetimeStart = ParentHitObject.LifetimeStart; + LifetimeEnd = ParentHitObject.LifetimeEnd; + } + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + base.CheckForResult(userTriggered, timeOffset); + if (timeOffset >= 0) + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } + } + #endregion } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs index c476aae202..bf9b13b320 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void AddCheckSteps() { AddUntilStep("score above zero", () => ((ScoreAccessibleReplayPlayer)Player).ScoreProcessor.TotalScore.Value > 0); - AddUntilStep("key counter counted keys", () => ((ScoreAccessibleReplayPlayer)Player).HUDOverlay.KeyCounter.Children.Any(kc => kc.CountPresses > 0)); + AddUntilStep("key counter counted keys", () => ((ScoreAccessibleReplayPlayer)Player).HUDOverlay.KeyCounter.Counters.Any(kc => kc.CountPresses.Value > 0)); AddAssert("cannot fail", () => !((ScoreAccessibleReplayPlayer)Player).AllowFail); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index 3e415af86e..ae10207de0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -8,6 +8,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps; using osuTK.Input; @@ -45,6 +46,18 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("Time still stopped", () => lastTime == Player.GameplayClockContainer.CurrentTime); } + [Test] + public void TestDoesNotFailOnExit() + { + loadPlayerWithBeatmap(); + + AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); + AddAssert("ensure rank is not fail", () => Player.ScoreProcessor.Rank.Value, () => Is.Not.EqualTo(ScoreRank.F)); + AddStep("exit player", () => Player.Exit()); + AddUntilStep("wait for exit", () => Player.Parent == null); + AddAssert("ensure rank is not fail", () => Player.ScoreProcessor.Rank.Value, () => Is.Not.EqualTo(ScoreRank.F)); + } + [Test] public void TestPauseViaSpaceWithSkip() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs index 8fff07e6d8..2b378c8013 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs @@ -22,6 +22,7 @@ using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring.Legacy; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -124,8 +125,8 @@ namespace osu.Game.Tests.Visual.Gameplay graphs.Clear(); legend.Clear(); - runForProcessor("lazer-standardised", Color4.YellowGreen, new ScoreProcessor(new OsuRuleset()) { Mode = { Value = ScoringMode.Standardised } }); - runForProcessor("lazer-classic", Color4.MediumPurple, new ScoreProcessor(new OsuRuleset()) { Mode = { Value = ScoringMode.Classic } }); + runForProcessor("lazer-standardised", Color4.YellowGreen, new ScoreProcessor(new OsuRuleset()), ScoringMode.Standardised); + runForProcessor("lazer-classic", Color4.MediumPurple, new ScoreProcessor(new OsuRuleset()), ScoringMode.Classic); runScoreV1(); runScoreV2(); @@ -218,7 +219,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void runForProcessor(string name, Color4 colour, ScoreProcessor processor) + private void runForProcessor(string name, Color4 colour, ScoreProcessor processor, ScoringMode mode) { int maxCombo = sliderMaxCombo.Current.Value; @@ -232,10 +233,10 @@ namespace osu.Game.Tests.Visual.Gameplay () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great }), () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok }), () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss }), - () => (int)processor.TotalScore.Value); + () => processor.GetDisplayScore(mode)); } - private void runForAlgorithm(string name, Color4 colour, Action applyHit, Action applyNonPerfect, Action applyMiss, Func getTotalScore) + private void runForAlgorithm(string name, Color4 colour, Action applyHit, Action applyNonPerfect, Action applyMiss, Func getTotalScore) { int maxCombo = sliderMaxCombo.Current.Value; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 9690d00d4c..7b37b6624d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -2,12 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Overlays.Settings; @@ -17,6 +20,8 @@ using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -52,6 +57,192 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for loaded", () => skinEditor.IsLoaded); } + [Test] + public void TestDragSelection() + { + BigBlackBox box1 = null!; + BigBlackBox box2 = null!; + BigBlackBox box3 = null!; + + AddStep("Add big black boxes", () => + { + var target = Player.ChildrenOfType().First(); + target.Add(box1 = new BigBlackBox + { + Position = new Vector2(-90), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + target.Add(box2 = new BigBlackBox + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + target.Add(box3 = new BigBlackBox + { + Position = new Vector2(90), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + }); + + // This step is specifically added to reproduce an edge case which was found during cyclic selection development. + // If everything is working as expected it should not affect the subsequent drag selections. + AddRepeatStep("Select top left", () => + { + InputManager.MoveMouseTo(box1.ScreenSpaceDrawQuad.TopLeft + new Vector2(box1.ScreenSpaceDrawQuad.Width / 8)); + InputManager.Click(MouseButton.Left); + }, 2); + + AddStep("Begin drag top left", () => + { + InputManager.MoveMouseTo(box1.ScreenSpaceDrawQuad.TopLeft - new Vector2(box1.ScreenSpaceDrawQuad.Width / 4)); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("Drag to bottom right", () => + { + InputManager.MoveMouseTo(box3.ScreenSpaceDrawQuad.TopRight + new Vector2(-box3.ScreenSpaceDrawQuad.Width / 8, box3.ScreenSpaceDrawQuad.Height / 4)); + }); + + AddStep("Release button", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("First two boxes selected", () => skinEditor.SelectedComponents, () => Is.EqualTo(new[] { box1, box2 })); + + AddStep("Begin drag bottom right", () => + { + InputManager.MoveMouseTo(box3.ScreenSpaceDrawQuad.BottomRight + new Vector2(box3.ScreenSpaceDrawQuad.Width / 4)); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("Drag to top left", () => + { + InputManager.MoveMouseTo(box2.ScreenSpaceDrawQuad.Centre - new Vector2(box2.ScreenSpaceDrawQuad.Width / 4)); + }); + + AddStep("Release button", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("Last two boxes selected", () => skinEditor.SelectedComponents, () => Is.EqualTo(new[] { box2, box3 })); + + // Test cyclic selection doesn't trigger in this state. + AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left)); + AddAssert("Last two boxes still selected", () => skinEditor.SelectedComponents, () => Is.EqualTo(new[] { box2, box3 })); + } + + [Test] + public void TestCyclicSelection() + { + SkinBlueprint[] blueprints = null!; + + AddStep("Add big black boxes", () => + { + InputManager.MoveMouseTo(skinEditor.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Three black boxes added", () => targetContainer.Components.OfType().Count(), () => Is.EqualTo(3)); + + AddStep("Store black box blueprints", () => + { + blueprints = skinEditor.ChildrenOfType().Where(b => b.Item is BigBlackBox).ToArray(); + }); + + AddAssert("Selection is black box 1", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[0].Item)); + + AddStep("move cursor to black box", () => + { + // Slightly offset from centre to avoid random failures (see https://github.com/ppy/osu-framework/issues/5669). + InputManager.MoveMouseTo(((Drawable)blueprints[0].Item).ScreenSpaceDrawQuad.Centre + new Vector2(1)); + }); + + AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left)); + AddAssert("Selection is black box 2", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[1].Item)); + + AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left)); + AddAssert("Selection is black box 3", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[2].Item)); + + AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left)); + AddAssert("Selection is black box 1", () => skinEditor.SelectedComponents.Single(), () => Is.EqualTo(blueprints[0].Item)); + + AddStep("select all boxes", () => + { + skinEditor.SelectedComponents.Clear(); + skinEditor.SelectedComponents.AddRange(targetContainer.Components.OfType().Skip(1)); + }); + + AddAssert("all boxes selected", () => skinEditor.SelectedComponents, () => Has.Count.EqualTo(2)); + AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left)); + AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left)); + AddStep("click on black box stack", () => InputManager.Click(MouseButton.Left)); + AddAssert("all boxes still selected", () => skinEditor.SelectedComponents, () => Has.Count.EqualTo(2)); + } + + [Test] + public void TestUndoEditHistory() + { + SkinComponentsContainer firstTarget = null!; + TestSkinEditorChangeHandler changeHandler = null!; + byte[] defaultState = null!; + IEnumerable testComponents = null!; + + AddStep("Load necessary things", () => + { + firstTarget = Player.ChildrenOfType().First(); + changeHandler = new TestSkinEditorChangeHandler(firstTarget); + + changeHandler.SaveState(); + defaultState = changeHandler.GetCurrentState(); + + testComponents = new[] + { + targetContainer.Components.First(), + targetContainer.Components[targetContainer.Components.Count / 2], + targetContainer.Components.Last() + }; + }); + + AddStep("Press undo", () => InputManager.Keys(PlatformAction.Undo)); + AddAssert("Nothing changed", () => defaultState.SequenceEqual(changeHandler.GetCurrentState())); + + AddStep("Add components", () => + { + InputManager.MoveMouseTo(skinEditor.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + revertAndCheckUnchanged(); + + AddStep("Move components", () => + { + changeHandler.BeginChange(); + testComponents.ForEach(c => ((Drawable)c).Position += Vector2.One); + changeHandler.EndChange(); + }); + revertAndCheckUnchanged(); + + AddStep("Select components", () => skinEditor.SelectedComponents.AddRange(testComponents)); + AddStep("Bring to front", () => skinEditor.BringSelectionToFront()); + revertAndCheckUnchanged(); + + AddStep("Remove components", () => testComponents.ForEach(c => firstTarget.Remove(c, false))); + revertAndCheckUnchanged(); + + void revertAndCheckUnchanged() + { + AddStep("Revert changes", () => changeHandler.RestoreState(int.MinValue)); + AddAssert("Current state is same as default", () => defaultState.SequenceEqual(changeHandler.GetCurrentState())); + } + } + [TestCase(false)] [TestCase(true)] public void TestBringToFront(bool alterSelectionOrder) @@ -139,5 +330,23 @@ namespace osu.Game.Tests.Visual.Gameplay } protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + private partial class TestSkinEditorChangeHandler : SkinEditorChangeHandler + { + public TestSkinEditorChangeHandler(Drawable targetScreen) + : base(targetScreen) + { + } + + public byte[] GetCurrentState() + { + using var stream = new MemoryStream(); + + WriteCurrentStateToStream(stream); + byte[] newState = stream.ToArray(); + + return newState; + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index a7da8f9832..4ae115a68d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Edit; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Gameplay; using osuTK.Input; @@ -21,8 +22,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneSkinEditorMultipleSkins : SkinnableTestScene { - [Cached] - private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor => gameplayState.ScoreProcessor; [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); @@ -57,7 +58,7 @@ namespace osu.Game.Tests.Visual.Gameplay }; // Add any key just to display the key counter visually. - hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); + hudOverlay.KeyCounter.Add(new KeyCounterKeyboardTrigger(Key.Space)); scoreProcessor.Combo.Value = 1; return new Container diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 1f2329af4a..89432940ba 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -18,6 +18,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Gameplay; using osuTK.Input; @@ -27,8 +28,8 @@ namespace osu.Game.Tests.Visual.Gameplay { private HUDOverlay hudOverlay; - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor => gameplayState.ScoreProcessor; [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); @@ -43,7 +44,7 @@ namespace osu.Game.Tests.Visual.Gameplay // best way to check without exposing. private Drawable hideTarget => hudOverlay.KeyCounter; - private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); + private Drawable keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().Single(); [Test] public void TestComboCounterIncrementing() @@ -88,7 +89,7 @@ namespace osu.Game.Tests.Visual.Gameplay hudOverlay = new HUDOverlay(null, Array.Empty()); // Add any key just to display the key counter visually. - hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); + hudOverlay.KeyCounter.Add(new KeyCounterKeyboardTrigger(Key.Space)); action?.Invoke(hudOverlay); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs index c95e8ee5b2..2cb3303dd6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs @@ -10,13 +10,14 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osu.Game.Tests.Gameplay; namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneSkinnableScoreCounter : SkinnableHUDComponentTestScene { - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor = TestGameplayState.Create(new OsuRuleset()).ScoreProcessor; protected override Drawable CreateDefaultImplementation() => new DefaultScoreCounter(); protected override Drawable CreateLegacyImplementation() => new LegacyScoreCounter(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index 5c69062e67..3f78dbfd96 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.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; @@ -22,8 +20,10 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneSkinnableSound : OsuTestScene { - private TestSkinSourceContainer skinSource; - private PausableSkinnableSound skinnableSound; + private TestSkinSourceContainer skinSource = null!; + private PausableSkinnableSound skinnableSound = null!; + + private const string sample_lookup = "Gameplay/normal-sliderslide"; [SetUpSteps] public void SetUpSteps() @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.Gameplay }; // has to be added after the hierarchy above else the `ISkinSource` dependency won't be cached. - skinSource.Add(skinnableSound = new PausableSkinnableSound(new SampleInfo("Gameplay/normal-sliderslide"))); + skinSource.Add(skinnableSound = new PausableSkinnableSound(new SampleInfo(sample_lookup))); }); } @@ -99,10 +99,28 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("sample not playing", () => !skinnableSound.IsPlaying); } + [Test] + public void TestSampleUpdatedBeforePlaybackWhenNotPresent() + { + AddStep("make sample non-present", () => skinnableSound.Hide()); + AddUntilStep("ensure not present", () => skinnableSound.IsPresent, () => Is.False); + + AddUntilStep("ensure sample loaded", () => skinnableSound.ChildrenOfType().Single().Name, () => Is.EqualTo(sample_lookup)); + + AddStep("change source", () => + { + skinSource.OverridingSample = new SampleVirtual("new skin"); + skinSource.TriggerSourceChanged(); + }); + + AddStep("start sample", () => skinnableSound.Play()); + AddUntilStep("sample updated", () => skinnableSound.ChildrenOfType().Single().Name, () => Is.EqualTo("new skin")); + } + [Test] public void TestSkinChangeDoesntPlayOnPause() { - DrawableSample sample = null; + DrawableSample? sample = null; AddStep("start sample", () => { skinnableSound.Play(); @@ -118,7 +136,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("retrieve and ensure current sample is different", () => { - DrawableSample oldSample = sample; + DrawableSample? oldSample = sample; sample = skinnableSound.ChildrenOfType().Single(); return sample != oldSample; }); @@ -134,20 +152,29 @@ namespace osu.Game.Tests.Visual.Gameplay private partial class TestSkinSourceContainer : Container, ISkinSource, ISamplePlaybackDisabler { [Resolved] - private ISkinSource source { get; set; } + private ISkinSource source { get; set; } = null!; - public event Action SourceChanged; + public event Action? SourceChanged; public Bindable SamplePlaybackDisabled { get; } = new Bindable(); + public ISample? OverridingSample; + IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => SamplePlaybackDisabled; - public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => source?.GetDrawableComponent(lookup); - public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); - public ISample GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); - public IBindable GetConfig(TLookup lookup) => source?.GetConfig(lookup); - public ISkin FindProvider(Func lookupFunction) => lookupFunction(this) ? this : source?.FindProvider(lookupFunction); - public IEnumerable AllSources => new[] { this }.Concat(source?.AllSources ?? Enumerable.Empty()); + public Drawable? GetDrawableComponent(ISkinComponentLookup lookup) => source.GetDrawableComponent(lookup); + public Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source.GetTexture(componentName, wrapModeS, wrapModeT); + public ISample? GetSample(ISampleInfo sampleInfo) => OverridingSample ?? source.GetSample(sampleInfo); + + public IBindable? GetConfig(TLookup lookup) + where TLookup : notnull + where TValue : notnull + { + return source.GetConfig(lookup); + } + + public ISkin? FindProvider(Func lookupFunction) => lookupFunction(this) ? this : source.FindProvider(lookupFunction); + public IEnumerable AllSources => new[] { this }.Concat(source.AllSources); public void TriggerSourceChanged() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs index 8ae6a2a5fc..dbd14db818 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs @@ -16,13 +16,14 @@ 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] - private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private readonly ScoreProcessor scoreProcessor = TestGameplayState.Create(new OsuRuleset()).ScoreProcessor; private readonly BindableList scores = new BindableList(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index dbce62cbef..a6663f3086 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; @@ -42,6 +44,18 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Load storyboard with missing video", () => loadStoryboard("storyboard_no_video.osu")); } + [Test] + public void TestVideoSize() + { + AddStep("load storyboard with only video", () => + { + // LegacyStoryboardDecoder doesn't parse WidescreenStoryboard, so it is set manually + loadStoryboard("storyboard_only_video.osu", s => s.BeatmapInfo.WidescreenStoryboard = false); + }); + + AddAssert("storyboard is correct width", () => Precision.AlmostEquals(storyboard?.Width ?? 0f, 480 * 16 / 9f)); + } + [BackgroundDependencyLoader] private void load() { @@ -102,7 +116,7 @@ namespace osu.Game.Tests.Visual.Gameplay decoupledClock.ChangeSource(Beatmap.Value.Track); } - private void loadStoryboard(string filename) + private void loadStoryboard(string filename, Action? setUpStoryboard = null) { Storyboard loaded; @@ -113,6 +127,8 @@ namespace osu.Game.Tests.Visual.Gameplay loaded = decoder.Decode(bfr); } + setUpStoryboard?.Invoke(loaded); + loadStoryboard(loaded); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index 0d88fb01a8..283866bef2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -13,6 +13,7 @@ using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu; @@ -106,6 +107,26 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); } + [Test] + public void TestSaveFailedReplayWithStoryboardEndedDoesNotProgress() + { + CreateTest(() => + { + AddStep("fail on first judgement", () => currentFailConditions = (_, _) => true); + AddStep("set storyboard duration to 0s", () => currentStoryboardDuration = 0); + }); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); + AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); + + AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); + AddUntilStep("wait for button clickable", () => Player.ChildrenOfType().First().ChildrenOfType().First().Enabled.Value); + AddStep("click save button", () => Player.ChildrenOfType().First().ChildrenOfType().First().TriggerClick()); + + // Test a regression where importing the fail replay would cause progression to results screen in a failed state. + AddWaitStep("wait some", 10); + AddAssert("player is still current screen", () => Player.IsCurrentScreen()); + } + [Test] public void TestShowResultsFalse() { diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index aef6f9ade0..22c7bb64b2 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -114,6 +114,19 @@ namespace osu.Game.Tests.Visual.Menus } } + [TestCase(OverlayActivation.All)] + [TestCase(OverlayActivation.Disabled)] + public void TestButtonKeyboardInputRespectsOverlayActivation(OverlayActivation mode) + { + AddStep($"set activation mode to {mode}", () => toolbar.OverlayActivationMode.Value = mode); + AddStep("hide toolbar", () => toolbar.Hide()); + + if (mode == OverlayActivation.Disabled) + AddAssert("check buttons not accepting input", () => InputManager.NonPositionalInputQueue.OfType().Count(), () => Is.Zero); + else + AddAssert("check buttons accepting input", () => InputManager.NonPositionalInputQueue.OfType().Count(), () => Is.Not.Zero); + } + [TestCase(OverlayActivation.All)] [TestCase(OverlayActivation.Disabled)] public void TestRespectsOverlayActivation(OverlayActivation mode) diff --git a/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs b/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs new file mode 100644 index 0000000000..6bdb9132e1 --- /dev/null +++ b/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Tests.Visual.Mods +{ + public partial class TestSceneModAccuracyChallenge : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + protected override TestPlayer CreateModPlayer(Ruleset ruleset) + { + var player = base.CreateModPlayer(ruleset); + return player; + } + + protected override bool AllowFail => true; + + [Test] + public void TestMaximumAchievableAccuracy() => + CreateModTest(new ModTestData + { + Mod = new ModAccuracyChallenge + { + MinimumAccuracy = { Value = 0.6 } + }, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = Enumerable.Range(0, 5).Select(i => new HitCircle + { + StartTime = i * 250, + Position = new Vector2(i * 50) + }).Cast().ToList() + }, + PassCondition = () => Player.GameplayState.HasFailed && Player.ScoreProcessor.JudgedHits >= 3 + }); + + [Test] + public void TestStandardAccuracy() => + CreateModTest(new ModTestData + { + Mod = new ModAccuracyChallenge + { + MinimumAccuracy = { Value = 0.6 }, + AccuracyJudgeMode = { Value = ModAccuracyChallenge.AccuracyMode.Standard } + }, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = Enumerable.Range(0, 5).Select(i => new HitCircle + { + StartTime = i * 250, + Position = new Vector2(i * 50) + }).Cast().ToList() + }, + PassCondition = () => Player.GameplayState.HasFailed && Player.ScoreProcessor.JudgedHits >= 1 + }); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 649c662e41..906eea9553 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -21,7 +21,6 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Multiplayer @@ -188,15 +187,12 @@ namespace osu.Game.Tests.Visual.Multiplayer if (!lastHeaders.TryGetValue(userId, out var header)) { - lastHeaders[userId] = header = new FrameHeader(new ScoreInfo + lastHeaders[userId] = header = new FrameHeader(0, 0, 0, 0, new Dictionary { - Statistics = new Dictionary - { - [HitResult.Miss] = 0, - [HitResult.Meh] = 0, - [HitResult.Great] = 0 - } - }); + [HitResult.Miss] = 0, + [HitResult.Meh] = 0, + [HitResult.Great] = 0 + }, new ScoreProcessorStatistics(), DateTimeOffset.Now); } switch (RNG.Next(0, 3)) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index 3efc7fbd30..6d309078e6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -129,7 +129,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Playlist = { - new MultiplayerPlaylistItem(playlistItem), + TestMultiplayerClient.CreateMultiplayerPlaylistItem(playlistItem), }, Users = { localUser }, Host = localUser, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index d747d23229..09624f63b7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -906,7 +906,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, new MultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, @@ -938,7 +938,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, new MultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index c0b6a0beab..9b130071cc 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -98,6 +98,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep($"select {requiredMod.ReadableName()} as required", () => songSelect.Mods.Value = new[] { (Mod)Activator.CreateInstance(requiredMod) }); AddAssert("freemods empty", () => songSelect.FreeMods.Value.Count == 0); + + // A previous test's mod overlay could still be fading out. + AddUntilStep("wait for only one freemod overlay", () => this.ChildrenOfType().Count() == 1); + assertHasFreeModButton(allowedMod, false); assertHasFreeModButton(requiredMod, false); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index d7578b4114..2100f82886 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () => { - MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) + MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) { Expired = expired, PlayedAt = DateTimeOffset.Now diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index bb37f1a5a7..47fb4e06ea 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -130,7 +130,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add playlist item", () => { - MultiplayerPlaylistItem item = new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); + MultiplayerPlaylistItem item = TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); MultiplayerClient.AddUserPlaylistItem(userId(), item).WaitSafely(); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs new file mode 100644 index 0000000000..1b2bb57b84 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -0,0 +1,146 @@ +// 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.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.GameplayTest; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; +using osu.Game.Tests.Resources; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Navigation +{ + public partial class TestSceneBeatmapEditorNavigation : OsuGameTestScene + { + [Test] + public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + AddStep("switch ruleset", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); + + AddStep("open editor", () => ((PlaySongSelect)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); + AddStep("test gameplay", () => ((Editor)Game.ScreenStack.CurrentScreen).TestGameplay()); + + AddUntilStep("wait for player", () => + { + // notifications may fire at almost any inopportune time and cause annoying test failures. + // relentlessly attempt to dismiss any and all interfering overlays, which includes notifications. + // this is theoretically not foolproof, but it's the best that can be done here. + Game.CloseAllOverlays(); + return Game.ScreenStack.CurrentScreen is EditorPlayer editorPlayer && editorPlayer.IsLoaded; + }); + + AddAssert("current ruleset is osu!", () => Game.Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("previous ruleset restored", () => Game.Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + } + + /// + /// When entering the editor, a new beatmap is created as part of the asynchronous load process. + /// This test ensures that in the case of an early exit from the editor (ie. while it's still loading) + /// doesn't leave a dangling beatmap behind. + /// + /// This may not fail 100% due to timing, but has a pretty high chance of hitting a failure so works well enough + /// as a test. + /// + [Test] + public void TestCancelNavigationToEditor() + { + BeatmapSetInfo[] beatmapSets = null!; + + AddStep("Fetch initial beatmaps", () => beatmapSets = allBeatmapSets()); + + AddStep("Set current beatmap to default", () => Game.Beatmap.SetDefault()); + + AddStep("Push editor loader", () => Game.ScreenStack.Push(new EditorLoader())); + AddUntilStep("Wait for loader current", () => Game.ScreenStack.CurrentScreen is EditorLoader); + AddStep("Close editor while loading", () => Game.ScreenStack.CurrentScreen.Exit()); + + AddUntilStep("Wait for menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + AddAssert("Check no new beatmaps were made", () => allBeatmapSets().SequenceEqual(beatmapSets)); + + BeatmapSetInfo[] allBeatmapSets() => Game.Realm.Run(realm => realm.All().Where(x => !x.DeletePending).ToArray()); + } + + [Test] + public void TestExitEditorWithoutSelection() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + + AddStep("open editor", () => ((PlaySongSelect)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); + + AddStep("escape once", () => InputManager.Key(Key.Escape)); + + AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); + } + + [Test] + public void TestExitEditorWithSelection() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + + AddStep("open editor", () => ((PlaySongSelect)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); + + AddStep("make selection", () => + { + var beatmap = getEditorBeatmap(); + beatmap.SelectedHitObjects.AddRange(beatmap.HitObjects.Take(5)); + }); + + AddAssert("selection exists", () => getEditorBeatmap().SelectedHitObjects, () => Has.Count.GreaterThan(0)); + + AddStep("escape once", () => InputManager.Key(Key.Escape)); + + AddAssert("selection empty", () => getEditorBeatmap().SelectedHitObjects, () => Has.Count.Zero); + + AddStep("escape again", () => InputManager.Key(Key.Escape)); + + AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); + } + + private EditorBeatmap getEditorBeatmap() => ((Editor)Game.ScreenStack.CurrentScreen).ChildrenOfType().Single(); + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs index d937b9e6d7..224e7e411e 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics.UserInterface; 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.Tests.Beatmaps.IO; using osuTK.Input; @@ -69,10 +70,10 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for gameplay", () => player?.IsBreakTime.Value == false); AddStep("press 'z'", () => InputManager.Key(Key.Z)); - AddAssert("key counter didn't increase", () => keyCounter.CountPresses == 0); + AddAssert("key counter didn't increase", () => keyCounter.CountPresses.Value == 0); AddStep("press 's'", () => InputManager.Key(Key.S)); - AddAssert("key counter did increase", () => keyCounter.CountPresses == 1); + AddAssert("key counter did increase", () => keyCounter.CountPresses.Value == 1); } private KeyBindingsSubsection osuBindingSubsection => keyBindingPanel diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 0d081e8138..18aef99ccd 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -17,6 +17,7 @@ using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; @@ -539,6 +540,11 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open()); AddStep("press back button", () => Game.ChildrenOfType().First().Action()); AddWaitStep("wait two frames", 2); + + AddStep("exit lounge", () => Game.ScreenStack.Exit()); + // `TestMultiplayerComponents` registers a request handler in its BDL, but never unregisters it. + // to prevent the handler living for longer than it should be, clean up manually. + AddStep("clean up multiplayer request handler", () => ((DummyAPIAccess)API).HandleRequest = null); } [Test] @@ -700,7 +706,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press escape twice rapidly", () => { InputManager.Key(Key.Escape); - InputManager.Key(Key.Escape); + Schedule(InputManager.Key, Key.Escape); }); pushEscape(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 5d13421195..a27c4ddad2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -14,6 +14,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Testing; using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -26,7 +27,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online { - public partial class TestSceneBeatmapSetOverlay : OsuTestScene + public partial class TestSceneBeatmapSetOverlay : OsuManualInputManagerTestScene { private readonly TestBeatmapSetOverlay overlay; @@ -281,6 +282,22 @@ namespace osu.Game.Tests.Visual.Online AddAssert(@"type is correct", () => type == lookupType.ToString()); } + [Test] + public void TestBeatmapSetWithGuestDifficulty() + { + AddStep("show map", () => overlay.ShowBeatmapSet(createBeatmapSetWithGuestDifficulty())); + AddStep("move mouse to host difficulty", () => + { + InputManager.MoveMouseTo(overlay.ChildrenOfType().ElementAt(0)); + }); + AddAssert("guest mapper information not shown", () => overlay.ChildrenOfType().Single().ChildrenOfType().All(s => s.Text != "BanchoBot")); + 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")); + } + private APIBeatmapSet createManyDifficultiesBeatmapSet() { var set = getBeatmapSet(); @@ -320,6 +337,60 @@ namespace osu.Game.Tests.Visual.Online return beatmapSet; } + private APIBeatmapSet createBeatmapSetWithGuestDifficulty() + { + var set = getBeatmapSet(); + + var beatmaps = new List(); + + var guestUser = new APIUser + { + Username = @"BanchoBot", + Id = 3, + }; + + set.RelatedUsers = new[] + { + set.Author, guestUser + }; + + beatmaps.Add(new APIBeatmap + { + OnlineID = 1145, + DifficultyName = "Host Diff", + RulesetID = Ruleset.Value.OnlineID, + StarRating = 1.4, + OverallDifficulty = 3.5f, + AuthorID = set.AuthorID, + FailTimes = new APIFailTimes + { + 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 + }); + + beatmaps.Add(new APIBeatmap + { + OnlineID = 1919, + DifficultyName = "Guest Diff", + RulesetID = Ruleset.Value.OnlineID, + StarRating = 8.1, + OverallDifficulty = 3.5f, + AuthorID = 3, + FailTimes = new APIFailTimes + { + 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 + }); + + set.Beatmaps = beatmaps.ToArray(); + + return set; + } + private void downloadAssert(bool shown) { AddAssert($"is download button {(shown ? "shown" : "hidden")}", () => overlay.Header.HeaderContent.DownloadButtonsVisible == shown); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index a8369dd6d9..55e6b54af7 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -8,10 +8,12 @@ using System.Linq; using System.Collections.Generic; using System.Net; using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; @@ -30,6 +32,7 @@ using osu.Game.Overlays.Chat.Listing; using osu.Game.Overlays.Chat.ChannelList; using osuTK; using osuTK.Input; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Tests.Visual.Online { @@ -53,6 +56,9 @@ namespace osu.Game.Tests.Visual.Online private int currentMessageId; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim(); + [SetUp] public void SetUp() => Schedule(() => { @@ -576,6 +582,75 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestChatReport() + { + ChatReportRequest request = null; + + AddStep("Show overlay with channel", () => + { + chatOverlay.Show(); + channelManager.CurrentChannel.Value = channelManager.JoinChannel(testChannel1); + }); + + AddAssert("Overlay is visible", () => chatOverlay.State.Value == Visibility.Visible); + waitForChannel1Visible(); + + AddStep("Setup request handling", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = r => + { + if (!(r is ChatReportRequest req)) + return false; + + Task.Run(() => + { + request = req; + requestLock.Wait(10000); + req.TriggerSuccess(); + }); + + return true; + }; + }); + + AddStep("Show report popover", () => this.ChildrenOfType().First().ShowPopover()); + + AddStep("Set report reason to other", () => + { + var reason = this.ChildrenOfType>().Single(); + reason.Current.Value = ChatReportReason.Other; + }); + + AddStep("Try to report", () => + { + var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); + InputManager.MoveMouseTo(btn); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Nothing happened", () => this.ChildrenOfType().Any()); + AddStep("Set report data", () => + { + var field = this.ChildrenOfType().Single().ChildrenOfType().Single(); + field.Current.Value = "test other"; + }); + + AddStep("Try to report", () => + { + var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); + InputManager.MoveMouseTo(btn); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Overlay closed", () => !this.ChildrenOfType().Any()); + AddStep("Complete request", () => requestLock.Set()); + AddUntilStep("Request sent", () => request != null); + AddUntilStep("Info message displayed", () => channelManager.CurrentChannel.Value.Messages.Last(), () => Is.InstanceOf(typeof(InfoMessage))); + } + private void joinTestChannel(int i) { AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i])); diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index a97c8aff66..4278c46d6a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -126,6 +126,7 @@ namespace osu.Game.Tests.Visual.Online Id = 13926, TournamentId = 35, ImageLowRes = "https://assets.ppy.sh/tournament-banners/official/owc2022/profile/winner_US.jpg", + Image = "https://assets.ppy.sh/tournament-banners/official/owc2022/profile/winner_US@2x.jpg", }, Badges = new[] { diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index b0e4303ca4..79c7e3a22e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.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 System.Net; @@ -20,7 +18,7 @@ namespace osu.Game.Tests.Visual.Online { private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - private WikiOverlay wiki; + private WikiOverlay wiki = null!; [SetUp] public void SetUp() => Schedule(() => Child = wiki = new WikiOverlay()); @@ -29,13 +27,13 @@ namespace osu.Game.Tests.Visual.Online public void TestMainPage() { setUpWikiResponse(responseMainPage); - AddStep("Show main page", () => wiki.Show()); + AddStep("Show main page", () => wiki.ShowPage()); } [Test] public void TestCancellationDoesntShowError() { - AddStep("Show main page", () => wiki.Show()); + AddStep("Show main page", () => wiki.ShowPage()); AddStep("Show another page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); AddUntilStep("Current path is not error", () => wiki.CurrentPath != "error"); @@ -73,7 +71,23 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("Error message correct", () => wiki.ChildrenOfType().Any(text => text.Text == "\"This_page_will_error_out\".")); } - private void setUpWikiResponse(APIWikiPage r, string redirectionPath = null) + [Test] + public void TestReturnAfterErrorPage() + { + setUpWikiResponse(responseArticlePage); + + AddStep("Show article page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); + AddUntilStep("Wait for non-error page", () => wiki.CurrentPath == "Article_styling_criteria/Formatting"); + + AddStep("Show nonexistent page", () => wiki.ShowPage("This_page_will_error_out")); + AddUntilStep("Wait for error page", () => wiki.CurrentPath == "error"); + + AddStep("Show article page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); + AddUntilStep("Wait for non-error page", () => wiki.CurrentPath == "Article_styling_criteria/Formatting"); + AddUntilStep("Error message not displayed", () => wiki.ChildrenOfType().All(text => text.Text != "\"This_page_will_error_out\".")); + } + + private void setUpWikiResponse(APIWikiPage r, string? redirectionPath = null) => AddStep("set up response", () => { dummyAPI.HandleRequest = request => diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index fcd5f97fcc..67211a3b72 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -174,78 +174,33 @@ namespace osu.Game.Tests.Visual.Ranking private class TestRulesetAllStatsRequireHitEvents : TestRuleset { - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] { - return new[] - { - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Requiring Hit Events 1", - () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) - } - }, - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Requiring Hit Events 2", - () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) - } - } - }; - } + new StatisticItem("Statistic Requiring Hit Events 1", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true), + new StatisticItem("Statistic Requiring Hit Events 2", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) + }; } private class TestRulesetNoStatsRequireHitEvents : TestRuleset { - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { return new[] { - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Not Requiring Hit Events 1", - () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) - } - }, - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Not Requiring Hit Events 2", - () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) - } - } + new StatisticItem("Statistic Not Requiring Hit Events 1", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")), + new StatisticItem("Statistic Not Requiring Hit Events 2", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) }; } } private class TestRulesetMixed : TestRuleset { - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { return new[] { - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Requiring Hit Events", - () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) - } - }, - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Not Requiring Hit Events", - () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) - } - } + new StatisticItem("Statistic Requiring Hit Events", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true), + new StatisticItem("Statistic Not Requiring Hit Events", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) }; } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index feab86d3ee..f094d40caa 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -1068,6 +1068,21 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("options disabled", () => !songSelect.ChildrenOfType().Single().Enabled.Value); } + [Test] + public void TestTextBoxBeatmapDifficultyCount() + { + createSongSelect(); + + AddAssert("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); + + addRulesetImportStep(0); + + AddAssert("3 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "3 matches"); + AddStep("delete all beatmaps", () => manager.Delete()); + AddUntilStep("wait for no beatmap", () => Beatmap.IsDefault); + AddAssert("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); + } + private void waitForInitialSelection() { AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs index cf0de14541..79baae53e8 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.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.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +11,6 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; -using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Select.Carousel; using osu.Game.Tests.Resources; @@ -143,25 +141,20 @@ namespace osu.Game.Tests.Visual.SongSelect testScoreInfo.User = API.LocalUser.Value; testScoreInfo.Rank = ScoreRank.B; - testScoreInfo.TotalScore = scoreManager.GetTotalScore(testScoreInfo, ScoringMode.Classic); scoreManager.Import(testScoreInfo); }); AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - AddStep("Add higher score for current user", () => + AddStep("Add higher-graded score for current user", () => { var testScoreInfo2 = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo2.User = API.LocalUser.Value; testScoreInfo2.Rank = ScoreRank.X; testScoreInfo2.Statistics = testScoreInfo2.MaximumStatistics; - testScoreInfo2.TotalScore = scoreManager.GetTotalScore(testScoreInfo2); - - // ensure second score has a total score (standardised) less than first one (classic) - // despite having better statistics, otherwise this test is pointless. - Debug.Assert(testScoreInfo2.TotalScore < testScoreInfo.TotalScore); + testScoreInfo2.TotalScore = testScoreInfo.TotalScore + 1; scoreManager.Import(testScoreInfo2); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs index 316035275f..dd7bf48791 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs @@ -14,10 +14,11 @@ using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneBeatmapListingSortTabControl : OsuTestScene + public partial class TestSceneBeatmapListingSortTabControl : OsuManualInputManagerTestScene { private readonly BeatmapListingSortTabControl control; @@ -111,6 +112,29 @@ namespace osu.Game.Tests.Visual.UserInterface resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Mine); } + [Test] + public void TestSortDirectionOnCriteriaChange() + { + AddStep("set category to leaderboard", () => control.Reset(SearchCategory.Leaderboard, false)); + AddAssert("sort direction is descending", () => control.SortDirection.Value == SortDirection.Descending); + + AddStep("click ranked sort button", () => + { + InputManager.MoveMouseTo(control.TabControl.ChildrenOfType().Single(s => s.Active.Value)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("sort direction is ascending", () => control.SortDirection.Value == SortDirection.Ascending); + + AddStep("click first inactive sort button", () => + { + InputManager.MoveMouseTo(control.TabControl.ChildrenOfType().First(s => !s.Active.Value)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("sort direction is descending", () => control.SortDirection.Value == SortDirection.Descending); + } + private void criteriaShowsOnCategory(bool expected, SortCriteria criteria, SearchCategory category) { AddAssert($"{criteria.ToString().ToLowerInvariant()} {(expected ? "shown" : "not shown")} on {category.ToString().ToLowerInvariant()}", () => diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs index 1090764788..3efdba8754 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -243,7 +243,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); AddStep("click delete", () => { - var deleteItem = this.ChildrenOfType().Single(); + var deleteItem = this.ChildrenOfType().ElementAt(1); InputManager.MoveMouseTo(deleteItem); InputManager.Click(MouseButton.Left); }); @@ -261,6 +261,137 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("preset soft-deleted", () => Realm.Run(r => r.All().Count(preset => preset.DeletePending) == 1)); } + [Test] + public void TestEditPresetName() + { + ModPresetColumn modPresetColumn = null!; + string presetName = null!; + ModPresetPanel panel = null!; + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = modPresetColumn = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); + AddStep("right click first panel", () => + { + panel = this.ChildrenOfType().First(); + presetName = panel.Preset.Value.Name; + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click edit", () => + { + var editItem = this.ChildrenOfType().ElementAt(0); + InputManager.MoveMouseTo(editItem); + InputManager.Click(MouseButton.Left); + }); + + OsuPopover? popover = null; + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("clear preset name", () => popover.ChildrenOfType().First().Current.Value = ""); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("preset is not changed", () => panel.Preset.Value.Name == presetName); + AddUntilStep("popover is unchanged", () => this.ChildrenOfType().FirstOrDefault() == popover); + AddStep("edit preset name", () => popover.ChildrenOfType().First().Current.Value = "something new"); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("popover closed", () => !this.ChildrenOfType().Any()); + AddAssert("preset is changed", () => panel.Preset.Value.Name != presetName); + } + + [Test] + public void TestEditPresetMod() + { + ModPresetColumn modPresetColumn = null!; + var mods = new Mod[] { new OsuModHidden(), new OsuModHardRock() }; + List previousMod = null!; + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = modPresetColumn = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); + + AddStep("right click first panel", () => + { + var panel = this.ChildrenOfType().First(); + previousMod = panel.Preset.Value.Mods.ToList(); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click edit", () => + { + var editItem = this.ChildrenOfType().ElementAt(0); + InputManager.MoveMouseTo(editItem); + InputManager.Click(MouseButton.Left); + }); + + OsuPopover? popover = null; + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("click use current mods", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(0)); + InputManager.Click(MouseButton.Left); + }); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("preset mod not changed", () => + new HashSet(this.ChildrenOfType().First().Preset.Value.Mods).SetEquals(previousMod)); + + AddStep("select mods", () => SelectedMods.Value = mods); + AddStep("right click first panel", () => + { + var panel = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click edit", () => + { + var editItem = this.ChildrenOfType().ElementAt(0); + InputManager.MoveMouseTo(editItem); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("click use current mods", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(0)); + InputManager.Click(MouseButton.Left); + }); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("preset mod is changed", () => + new HashSet(this.ChildrenOfType().First().Preset.Value.Mods).SetEquals(mods)); + } + private ICollection createTestPresets() => new[] { new ModPreset diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 5ccaebd721..5cf24c1960 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -14,6 +14,7 @@ using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Settings; using osu.Game.Rulesets; @@ -21,6 +22,7 @@ using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Tests.Mods; using osuTK; using osuTK.Input; @@ -67,6 +69,19 @@ namespace osu.Game.Tests.Visual.UserInterface } } }); + r.Add(new ModPreset + { + Name = "Half Time 0.5x", + Description = "Very slow", + Ruleset = r.Find(OsuRuleset.SHORT_NAME), + Mods = new[] + { + new OsuModHalfTime + { + SpeedChange = { Value = 0.5 } + } + } + }); }); }); } @@ -371,6 +386,50 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("no mod selected", () => SelectedMods.Value.Count == 0); } + [Test] + public void TestKeepSharedSettingsFromSimilarMods() + { + const float setting_change = 1.2f; + + createScreen(); + changeRuleset(0); + + AddStep("select difficulty adjust mod", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateMod()! }); + + changeRuleset(0); + AddAssert("ensure mod still selected", () => SelectedMods.Value.SingleOrDefault() is OsuModDifficultyAdjust); + + AddStep("change mod settings", () => + { + var osuMod = getSelectedMod(); + + osuMod.ExtendedLimits.Value = true; + osuMod.CircleSize.Value = setting_change; + osuMod.DrainRate.Value = setting_change; + osuMod.OverallDifficulty.Value = setting_change; + osuMod.ApproachRate.Value = setting_change; + }); + + changeRuleset(1); + AddAssert("taiko variant selected", () => SelectedMods.Value.SingleOrDefault() is TaikoModDifficultyAdjust); + + AddAssert("shared settings preserved", () => + { + var taikoMod = getSelectedMod(); + + return taikoMod.ExtendedLimits.Value && + taikoMod.DrainRate.Value == setting_change && + taikoMod.OverallDifficulty.Value == setting_change; + }); + + AddAssert("non-shared settings remain default", () => + { + var taikoMod = getSelectedMod(); + + return taikoMod.ScrollSpeed.IsDefault; + }); + } + [Test] public void TestExternallySetCustomizedMod() { @@ -566,6 +625,28 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("5 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 5); } + [Test] + public void TestModMultiplierUpdates() + { + createScreen(); + + AddStep("select mod preset with half time", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single(preset => preset.Preset.Value.Name == "Half Time 0.5x")); + InputManager.Click(MouseButton.Left); + }); + AddAssert("difficulty multiplier display shows correct value", () => modSelectOverlay.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(0.5)); + + // this is highly unorthodox in a test, but because the `ModSettingChangeTracker` machinery heavily leans on events and object disposal and re-creation, + // it is instrumental in the reproduction of the failure scenario that this test is supposed to cover. + AddStep("force collection", GC.Collect); + + AddStep("open customisation area", () => modSelectOverlay.CustomisationButton!.TriggerClick()); + AddStep("reset half time speed to default", () => modSelectOverlay.ChildrenOfType().Single() + .ChildrenOfType>().Single().TriggerClick()); + AddUntilStep("difficulty multiplier display shows correct value", () => modSelectOverlay.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(0.7)); + } + private void waitForColumnLoad() => AddUntilStep("all column content loaded", () => modSelectOverlay.ChildrenOfType().Any() && modSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); @@ -581,6 +662,8 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Value == active); } + private T getSelectedMod() where T : Mod => SelectedMods.Value.OfType().Single(); + private ModPanel getPanelForMod(Type modType) => modSelectOverlay.ChildrenOfType().Single(panel => panel.Mod.GetType() == modType); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs index 1144b9053d..320ec48e07 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; +using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; @@ -137,8 +138,7 @@ namespace osu.Game.Tests.Visual.UserInterface if (!objects.Any()) return; - double firstHit = objects.First().StartTime; - double lastHit = objects.Max(o => o.GetEndTime()); + (double firstHit, double lastHit) = BeatmapExtensions.CalculatePlayableBounds(objects); if (lastHit == 0) lastHit = objects.Last().StartTime; diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 7e19cb3aa5..634cc87a9f 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -332,13 +332,6 @@ namespace osu.Game.Tournament private void saveChanges() { - foreach (var r in ladder.Rounds) - r.Matches = ladder.Matches.Where(p => p.Round.Value == r).Select(p => p.ID).ToList(); - - ladder.Progressions = ladder.Matches.Where(p => p.Progression.Value != null).Select(p => new TournamentProgression(p.ID, p.Progression.Value.ID)).Concat( - ladder.Matches.Where(p => p.LosersProgression.Value != null).Select(p => new TournamentProgression(p.ID, p.LosersProgression.Value.ID, true))) - .ToList(); - // Serialise before opening stream for writing, so if there's a failure it will leave the file in the previous state. string serialisedLadder = GetSerialisedLadder(); @@ -349,6 +342,13 @@ namespace osu.Game.Tournament public string GetSerialisedLadder() { + foreach (var r in ladder.Rounds) + r.Matches = ladder.Matches.Where(p => p.Round.Value == r).Select(p => p.ID).ToList(); + + ladder.Progressions = ladder.Matches.Where(p => p.Progression.Value != null).Select(p => new TournamentProgression(p.ID, p.Progression.Value.ID)).Concat( + ladder.Matches.Where(p => p.LosersProgression.Value != null).Select(p => new TournamentProgression(p.ID, p.LosersProgression.Value.ID, true))) + .ToList(); + return JsonConvert.SerializeObject(ladder, new JsonSerializerSettings { diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index efa5562cb8..9573a9a4aa 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Utils; namespace osu.Game.Audio @@ -19,11 +20,20 @@ namespace osu.Game.Audio public const string HIT_FINISH = @"hitfinish"; public const string HIT_CLAP = @"hitclap"; + public const string BANK_NORMAL = @"normal"; + public const string BANK_SOFT = @"soft"; + public const string BANK_DRUM = @"drum"; + /// /// All valid sample addition constants. /// public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_FINISH, HIT_CLAP }; + /// + /// All valid bank constants. + /// + public static IEnumerable AllBanks => new[] { BANK_NORMAL, BANK_SOFT, BANK_DRUM }; + /// /// The name of the sample to load. /// @@ -32,7 +42,7 @@ namespace osu.Game.Audio /// /// The bank to load the sample from. /// - public readonly string? Bank; + public readonly string Bank; /// /// An optional suffix to provide priority lookup. Falls back to non-suffixed . @@ -44,7 +54,7 @@ namespace osu.Game.Audio /// public int Volume { get; } - public HitSampleInfo(string name, string? bank = null, string? suffix = null, int volume = 0) + public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100) { Name = name; Bank = bank; @@ -75,7 +85,7 @@ namespace osu.Game.Audio /// An optional new lookup suffix. /// An optional new volume. /// The new . - public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) + public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume)); public bool Equals(HitSampleInfo? other) diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 416d655cc3..4f81b26c3e 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -107,9 +107,12 @@ namespace osu.Game.Beatmaps // Aggregate durations into a set of (beatLength, duration) tuples for each beat length .GroupBy(t => Math.Round(t.beatLength * 1000) / 1000) .Select(g => (beatLength: g.Key, duration: g.Sum(t => t.duration))) - // Get the most common one, or 0 as a suitable default + // Get the most common one, or 0 as a suitable default (see handling below) .OrderByDescending(i => i.duration).FirstOrDefault(); + if (mostCommon.beatLength == 0) + return TimingControlPoint.DEFAULT_BEAT_LENGTH; + return mostCommon.beatLength; } diff --git a/osu.Game/Beatmaps/BeatmapDifficulty.cs b/osu.Game/Beatmaps/BeatmapDifficulty.cs index f4bc5e7b77..217f3b89a4 100644 --- a/osu.Game/Beatmaps/BeatmapDifficulty.cs +++ b/osu.Game/Beatmaps/BeatmapDifficulty.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. -using osu.Framework.Testing; using Realms; namespace osu.Game.Beatmaps { - [ExcludeFromDynamicCompile] [MapTo("BeatmapDifficulty")] public class BeatmapDifficulty : EmbeddedObject, IBeatmapDifficultyInfo { diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 4752a88199..7d367ef77d 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -12,7 +12,6 @@ using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Collections; using osu.Game.Database; @@ -28,14 +27,13 @@ namespace osu.Game.Beatmaps /// /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. /// - [ExcludeFromDynamicCompile] public class BeatmapImporter : RealmArchiveModelImporter { public override IEnumerable HandledExtensions => new[] { ".osz" }; protected override string[] HashableFileTypes => new[] { ".osu" }; - public Action<(BeatmapSetInfo beatmapSet, bool isBatch)>? ProcessBeatmap { private get; set; } + public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; } public BeatmapImporter(Storage storage, RealmAccess realm) : base(storage, realm) @@ -59,7 +57,7 @@ namespace osu.Game.Beatmaps first.PerformRead(s => { // Re-run processing even in this case. We might have outdated metadata. - ProcessBeatmap?.Invoke((s, false)); + ProcessBeatmap?.Invoke(s, MetadataLookupScope.OnlineFirst); }); return first; } @@ -206,7 +204,7 @@ namespace osu.Game.Beatmaps protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters) { base.PostImport(model, realm, parameters); - ProcessBeatmap?.Invoke((model, parameters.Batch)); + ProcessBeatmap?.Invoke(model, parameters.Batch ? MetadataLookupScope.LocalCacheFirst : MetadataLookupScope.OnlineFirst); } private void validateOnlineIds(BeatmapSetInfo beatmapSet, Realm realm) diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 3208598f56..393feff087 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Testing; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Collections; using osu.Game.Database; @@ -27,7 +26,6 @@ namespace osu.Game.Beatmaps /// /// There are some legacy fields in this model which are not persisted to realm. These are isolated in a code region within the class and should eventually be migrated to `Beatmap`. /// - [ExcludeFromDynamicCompile] [Serializable] [MapTo("Beatmap")] public class BeatmapInfo : RealmObject, IHasGuidPrimaryKey, IBeatmapInfo, IEquatable @@ -167,7 +165,7 @@ namespace osu.Game.Beatmaps /// public double DistanceSpacing { get; set; } = 1.0; - public int BeatDivisor { get; set; } + public int BeatDivisor { get; set; } = 4; public int GridSize { get; set; } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index ad56bbbc3a..305dc01844 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -15,7 +15,6 @@ using osu.Framework.Audio.Track; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Platform; -using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.Extensions; @@ -33,7 +32,6 @@ namespace osu.Game.Beatmaps /// /// Handles general operations related to global beatmap management. /// - [ExcludeFromDynamicCompile] public class BeatmapManager : ModelManager, IModelImporter, IWorkingBeatmapCache { public ITrackStore BeatmapTrackStore { get; } @@ -42,7 +40,9 @@ namespace osu.Game.Beatmaps private readonly WorkingBeatmapCache workingBeatmapCache; - public Action<(BeatmapSetInfo beatmapSet, bool isBatch)>? ProcessBeatmap { private get; set; } + private readonly LegacyBeatmapExporter beatmapExporter; + + public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; } public override bool PauseImports { @@ -72,10 +72,15 @@ namespace osu.Game.Beatmaps BeatmapTrackStore = audioManager.GetTrackStore(userResources); beatmapImporter = CreateBeatmapImporter(storage, realm); - beatmapImporter.ProcessBeatmap = args => ProcessBeatmap?.Invoke(args); + beatmapImporter.ProcessBeatmap = (beatmapSet, scope) => ProcessBeatmap?.Invoke(beatmapSet, scope); beatmapImporter.PostNotification = obj => PostNotification?.Invoke(obj); workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host); + + beatmapExporter = new LegacyBeatmapExporter(storage) + { + PostNotification = obj => PostNotification?.Invoke(obj) + }; } protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap? defaultBeatmap, @@ -368,7 +373,7 @@ namespace osu.Game.Beatmaps // user requested abort return; - var video = b.Files.FirstOrDefault(f => OsuGameBase.VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.Ordinal))); + var video = b.Files.FirstOrDefault(f => OsuGameBase.VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.OrdinalIgnoreCase))); if (video != null) { @@ -393,6 +398,8 @@ namespace osu.Game.Beatmaps public Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) => beatmapImporter.ImportAsUpdate(notification, importTask, original); + public Task Export(BeatmapSetInfo beatmap) => beatmapExporter.ExportAsync(beatmap.ToLive(Realm)); + private void updateHashAndMarkDirty(BeatmapSetInfo setInfo) { setInfo.Hash = beatmapImporter.ComputeHash(setInfo); @@ -415,6 +422,13 @@ namespace osu.Game.Beatmaps // All changes to metadata are made in the provided beatmapInfo, so this should be copied to the `IBeatmap` before encoding. 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(); + using (var stream = new MemoryStream()) { using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) @@ -438,9 +452,6 @@ namespace osu.Game.Beatmaps beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); beatmapInfo.Hash = stream.ComputeSHA2Hash(); - beatmapInfo.LastLocalUpdate = DateTimeOffset.Now; - beatmapInfo.Status = BeatmapOnlineStatus.LocallyModified; - AddFile(setInfo, stream, createBeatmapFilenameFromMetadata(beatmapInfo)); updateHashAndMarkDirty(setInfo); @@ -454,7 +465,9 @@ namespace osu.Game.Beatmaps if (transferCollections) beatmapInfo.TransferCollectionReferences(r, oldMd5Hash); - ProcessBeatmap?.Invoke((liveBeatmapSet, false)); + // do not look up metadata. + // this is a locally-modified set now, so looking up metadata is busy work at best and harmful at worst. + ProcessBeatmap?.Invoke(liveBeatmapSet, MetadataLookupScope.None); }); } @@ -542,4 +555,11 @@ namespace osu.Game.Beatmaps public override string HumanisedModelName => "beatmap"; } + + /// + /// Delegate type for beatmap processing callbacks. + /// + /// The beatmap set to be processed. + /// The scope to use when looking up metadata. + public delegate void ProcessBeatmapDelegate(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope); } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index f645d914b1..811dc54e16 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -4,7 +4,6 @@ using System; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Testing; using osu.Game.Models; using osu.Game.Users; using osu.Game.Utils; @@ -23,7 +22,6 @@ namespace osu.Game.Beatmaps /// /// Note that difficulty name is not stored in this metadata but in . /// - [ExcludeFromDynamicCompile] [Serializable] [MapTo("BeatmapMetadata")] public class BeatmapMetadata : RealmObject, IBeatmapMetadataInfo, IDeepCloneable diff --git a/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs index 98aefd75d3..b160043820 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs +++ b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs @@ -36,7 +36,7 @@ namespace osu.Game.Beatmaps var matchingSet = r.All().FirstOrDefault(s => s.OnlineID == id); if (matchingSet != null) - beatmapUpdater.Queue(matchingSet.ToLive(realm), true); + beatmapUpdater.Queue(matchingSet.ToLive(realm), MetadataLookupScope.OnlineFirst); } }); } diff --git a/osu.Game/Beatmaps/BeatmapProcessor.cs b/osu.Game/Beatmaps/BeatmapProcessor.cs index 8f3d0b7445..fb5313469f 100644 --- a/osu.Game/Beatmaps/BeatmapProcessor.cs +++ b/osu.Game/Beatmaps/BeatmapProcessor.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.Linq; using osu.Game.Rulesets.Objects.Types; @@ -22,34 +20,17 @@ namespace osu.Game.Beatmaps public virtual void PreProcess() { - IHasComboInformation lastObj = null; - - bool isFirst = true; + IHasComboInformation? lastObj = null; foreach (var obj in Beatmap.HitObjects.OfType()) { - if (isFirst) + if (lastObj == null) { - obj.NewCombo = true; - // first hitobject should always be marked as a new combo for sanity. - isFirst = false; - } - - obj.ComboIndex = lastObj?.ComboIndex ?? 0; - obj.ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - obj.IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; - - if (obj.NewCombo) - { - obj.IndexInCurrentCombo = 0; - obj.ComboIndex++; - obj.ComboIndexWithOffsets += obj.ComboOffset + 1; - - if (lastObj != null) - lastObj.LastInCombo = true; + obj.NewCombo = true; } + obj.UpdateComboInformation(lastObj); lastObj = obj; } } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index b90dfdba05..59e413d935 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Models; @@ -17,7 +16,6 @@ namespace osu.Game.Beatmaps /// /// A realm model containing metadata for a beatmap set (containing multiple s). /// - [ExcludeFromDynamicCompile] [MapTo("BeatmapSet")] public class BeatmapSetInfo : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IBeatmapSetInfo { diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index d7b1fac7b3..046adb8327 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; @@ -11,7 +10,6 @@ using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Database; using osu.Game.Online.API; -using osu.Game.Rulesets.Objects; namespace osu.Game.Beatmaps { @@ -42,24 +40,25 @@ namespace osu.Game.Beatmaps /// Queue a beatmap for background processing. /// /// The managed beatmap set to update. A transaction will be opened to apply changes. - /// Whether metadata from an online source should be preferred. If true, the local cache will be skipped to ensure the freshest data state possible. - public void Queue(Live beatmapSet, bool preferOnlineFetch = false) + /// The preferred scope to use for metadata lookup. + public void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) { Logger.Log($"Queueing change for local beatmap {beatmapSet}"); - Task.Factory.StartNew(() => beatmapSet.PerformRead(b => Process(b, preferOnlineFetch)), default, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + Task.Factory.StartNew(() => beatmapSet.PerformRead(b => Process(b, lookupScope)), default, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); } /// /// Run all processing on a beatmap immediately. /// /// The managed beatmap set to update. A transaction will be opened to apply changes. - /// Whether metadata from an online source should be preferred. If true, the local cache will be skipped to ensure the freshest data state possible. - public void Process(BeatmapSetInfo beatmapSet, bool preferOnlineFetch = false) => beatmapSet.Realm.Write(r => + /// The preferred scope to use for metadata lookup. + public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm.Write(r => { // Before we use below, we want to invalidate. workingBeatmapCache.Invalidate(beatmapSet); - metadataLookup.Update(beatmapSet, preferOnlineFetch); + if (lookupScope != MetadataLookupScope.None) + metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst); foreach (var beatmap in beatmapSet.Beatmaps) { @@ -73,7 +72,7 @@ namespace osu.Game.Beatmaps var calculator = ruleset.CreateDifficultyCalculator(working); beatmap.StarRating = calculator.Calculate().StarRating; - beatmap.Length = calculateLength(working.Beatmap); + beatmap.Length = working.Beatmap.CalculatePlayableLength(); beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); } @@ -81,20 +80,6 @@ namespace osu.Game.Beatmaps workingBeatmapCache.Invalidate(beatmapSet); }); - private double calculateLength(IBeatmap b) - { - if (!b.HitObjects.Any()) - return 0; - - var lastObject = b.HitObjects.Last(); - - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - double endTime = lastObject.GetEndTime(); - double startTime = b.HitObjects.First().StartTime; - - return endTime - startTime; - } - #region Implementation of IDisposable public void Dispose() diff --git a/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs index 71d40b1a48..fac91c23f5 100644 --- a/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs +++ b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs @@ -13,7 +13,6 @@ using osu.Framework.Development; using osu.Framework.IO.Network; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -30,7 +29,6 @@ namespace osu.Game.Beatmaps /// On creating the component, a copy of a database containing metadata for a large subset of beatmaps (stored to ) will be downloaded if not already present locally. /// This will always be checked before doing a second online query to get required metadata. /// - [ExcludeFromDynamicCompile] public class BeatmapUpdaterMetadataLookup : IDisposable { private readonly IAPIProvider api; diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index c454439c5c..ae4bdafd6f 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -14,7 +14,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// public class SampleControlPoint : ControlPoint, IEquatable { - public const string DEFAULT_BANK = "normal"; + public const string DEFAULT_BANK = HitSampleInfo.BANK_NORMAL; public static readonly SampleControlPoint DEFAULT = new SampleControlPoint { @@ -30,7 +30,7 @@ namespace osu.Game.Beatmaps.ControlPoints public readonly Bindable SampleBankBindable = new Bindable(DEFAULT_BANK) { Default = DEFAULT_BANK }; /// - /// The speed multiplier at this control point. + /// The default sample bank at this control point. /// public string SampleBank { @@ -39,7 +39,7 @@ namespace osu.Game.Beatmaps.ControlPoints } /// - /// The default sample bank at this control point. + /// The default sample volume at this control point. /// public readonly BindableInt SampleVolumeBindable = new BindableInt(100) { @@ -69,7 +69,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// The . This will not be modified. /// The modified . This does not share a reference with . public virtual HitSampleInfo ApplyTo(HitSampleInfo hitSampleInfo) - => hitSampleInfo.With(newBank: hitSampleInfo.Bank ?? SampleBank, newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume); + => hitSampleInfo.With(newBank: hitSampleInfo.Bank, newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume); public override bool IsRedundant(ControlPoint? existing) => existing is SampleControlPoint existingSample diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index eabc63b341..65a01befb4 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -3,6 +3,8 @@ #nullable disable +#pragma warning disable 618 + using System; using System.Collections.Generic; using System.IO; @@ -10,12 +12,15 @@ using System.Linq; using osu.Framework.Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Logging; +using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; using osu.Game.IO; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Beatmaps.Formats { @@ -26,6 +31,11 @@ namespace osu.Game.Beatmaps.Formats /// public const int EARLY_VERSION_TIMING_OFFSET = 24; + /// + /// A small adjustment to the start time of control points to account for rounding/precision errors. + /// + private const double control_point_leniency = 1; + internal static RulesetStore RulesetStore; private Beatmap beatmap; @@ -85,7 +95,45 @@ namespace osu.Game.Beatmaps.Formats this.beatmap.HitObjects = this.beatmap.HitObjects.OrderBy(h => h.StartTime).ToList(); foreach (var hitObject in this.beatmap.HitObjects) - hitObject.ApplyDefaults(this.beatmap.ControlPointInfo, this.beatmap.Difficulty); + { + applyDefaults(hitObject); + applySamples(hitObject); + } + } + + private void applyDefaults(HitObject hitObject) + { + DifficultyControlPoint difficultyControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.DifficultyPointAt(hitObject.StartTime) ?? DifficultyControlPoint.DEFAULT; + + if (difficultyControlPoint is LegacyDifficultyControlPoint legacyDifficultyControlPoint) + { + hitObject.LegacyBpmMultiplier = legacyDifficultyControlPoint.BpmMultiplier; + if (hitObject is IHasGenerateTicks hasGenerateTicks) + hasGenerateTicks.GenerateTicks = legacyDifficultyControlPoint.GenerateTicks; + } + + if (hitObject is IHasSliderVelocity hasSliderVelocity) + hasSliderVelocity.SliderVelocity = difficultyControlPoint.SliderVelocity; + + hitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + } + + private void applySamples(HitObject hitObject) + { + SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + control_point_leniency) ?? SampleControlPoint.DEFAULT; + + hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); + + if (hitObject is IHasRepeats hasRepeats) + { + for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) + { + double time = hitObject.StartTime + i * hasRepeats.Duration / hasRepeats.SpanCount() + control_point_leniency; + var nodeSamplePoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(time) ?? SampleControlPoint.DEFAULT; + + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(o => nodeSamplePoint.ApplyTo(o)).ToList(); + } + } } /// @@ -337,11 +385,11 @@ namespace osu.Game.Beatmaps.Formats break; case @"SliderMultiplier": - difficulty.SliderMultiplier = Parsing.ParseDouble(pair.Value); + difficulty.SliderMultiplier = Math.Clamp(Parsing.ParseDouble(pair.Value), 0.4, 3.6); break; case @"SliderTickRate": - difficulty.SliderTickRate = Parsing.ParseDouble(pair.Value); + difficulty.SliderTickRate = Math.Clamp(Parsing.ParseDouble(pair.Value), 0.5, 8); break; } } @@ -363,6 +411,19 @@ namespace osu.Game.Beatmaps.Formats beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[3]); break; + case LegacyEventType.Video: + string filename = CleanFilename(split[2]); + + // Some very old beatmaps had incorrect type specifications for their backgrounds (ie. using 1 for VIDEO + // instead of 0 for BACKGROUND). To handle this gracefully, check the file extension against known supported + // video extensions and handle similar to a background if it doesn't match. + if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant())) + { + beatmap.BeatmapInfo.Metadata.BackgroundFile = filename; + } + + break; + case LegacyEventType.Background: beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); break; @@ -420,7 +481,7 @@ namespace osu.Game.Beatmaps.Formats string stringSampleSet = sampleSet.ToString().ToLowerInvariant(); if (stringSampleSet == @"none") - stringSampleSet = @"normal"; + stringSampleSet = HitSampleInfo.BANK_NORMAL; if (timingChange) { @@ -438,9 +499,7 @@ namespace osu.Game.Beatmaps.Formats int onlineRulesetID = beatmap.BeatmapInfo.Ruleset.OnlineID; -#pragma warning disable 618 addControlPoint(time, new LegacyDifficultyControlPoint(onlineRulesetID, beatLength) -#pragma warning restore 618 { SliderVelocity = speedMultiplier, }, timingChange); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 072e442dea..a5fc815a5e 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -92,7 +92,8 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.BeatmapInfo.AudioLeadIn}")); writer.WriteLine(FormattableString.Invariant($"PreviewTime: {beatmap.Metadata.PreviewTime}")); writer.WriteLine(FormattableString.Invariant($"Countdown: {(int)beatmap.BeatmapInfo.Countdown}")); - writer.WriteLine(FormattableString.Invariant($"SampleSet: {toLegacySampleBank((beatmap.HitObjects.FirstOrDefault()?.SampleControlPoint ?? SampleControlPoint.DEFAULT).SampleBank)}")); + writer.WriteLine(FormattableString.Invariant( + $"SampleSet: {toLegacySampleBank(((beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePoints?.FirstOrDefault() ?? SampleControlPoint.DEFAULT).SampleBank)}")); writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.BeatmapInfo.StackLeniency}")); writer.WriteLine(FormattableString.Invariant($"Mode: {onlineRulesetID}")); writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.BeatmapInfo.LetterboxInBreaks ? '1' : '0')}")); @@ -173,9 +174,6 @@ namespace osu.Game.Beatmaps.Formats private void handleControlPoints(TextWriter writer) { - if (beatmap.ControlPointInfo.Groups.Count == 0) - return; - var legacyControlPoints = new LegacyControlPointInfo(); foreach (var point in beatmap.ControlPointInfo.AllControlPoints) legacyControlPoints.Add(point.Time, point.DeepClone()); @@ -199,33 +197,43 @@ namespace osu.Game.Beatmaps.Formats legacyControlPoints.Add(point.Time, new DifficultyControlPoint { SliderVelocity = point.ScrollSpeed }); } + LegacyControlPointProperties lastControlPointProperties = new LegacyControlPointProperties(); + foreach (var group in legacyControlPoints.Groups) { var groupTimingPoint = group.ControlPoints.OfType().FirstOrDefault(); + var controlPointProperties = getLegacyControlPointProperties(group, groupTimingPoint != null); // If the group contains a timing control point, it needs to be output separately. if (groupTimingPoint != null) { writer.Write(FormattableString.Invariant($"{groupTimingPoint.Time},")); writer.Write(FormattableString.Invariant($"{groupTimingPoint.BeatLength},")); - outputControlPointAt(groupTimingPoint.Time, true); + outputControlPointAt(controlPointProperties, true); + lastControlPointProperties = controlPointProperties; + lastControlPointProperties.SliderVelocity = 1; } + if (controlPointProperties.IsRedundant(lastControlPointProperties)) + continue; + // Output any remaining effects as secondary non-timing control point. - var difficultyPoint = legacyControlPoints.DifficultyPointAt(group.Time); writer.Write(FormattableString.Invariant($"{group.Time},")); - writer.Write(FormattableString.Invariant($"{-100 / difficultyPoint.SliderVelocity},")); - outputControlPointAt(group.Time, false); + writer.Write(FormattableString.Invariant($"{-100 / controlPointProperties.SliderVelocity},")); + outputControlPointAt(controlPointProperties, false); + lastControlPointProperties = controlPointProperties; } - void outputControlPointAt(double time, bool isTimingPoint) + LegacyControlPointProperties getLegacyControlPointProperties(ControlPointGroup group, bool updateSampleBank) { - var samplePoint = legacyControlPoints.SamplePointAt(time); - var effectPoint = legacyControlPoints.EffectPointAt(time); - var timingPoint = legacyControlPoints.TimingPointAt(time); + var timingPoint = legacyControlPoints.TimingPointAt(group.Time); + var difficultyPoint = legacyControlPoints.DifficultyPointAt(group.Time); + var samplePoint = legacyControlPoints.SamplePointAt(group.Time); + var effectPoint = legacyControlPoints.EffectPointAt(group.Time); // Apply the control point to a hit sample to uncover legacy properties (e.g. suffix) HitSampleInfo tempHitSample = samplePoint.ApplyTo(new ConvertHitObjectParser.LegacyHitSampleInfo(string.Empty)); + int customSampleBank = toLegacyCustomSampleBank(tempHitSample); // Convert effect flags to the legacy format LegacyEffectFlags effectFlags = LegacyEffectFlags.None; @@ -234,12 +242,26 @@ namespace osu.Game.Beatmaps.Formats if (timingPoint.OmitFirstBarLine) effectFlags |= LegacyEffectFlags.OmitFirstBarLine; - writer.Write(FormattableString.Invariant($"{timingPoint.TimeSignature.Numerator},")); - writer.Write(FormattableString.Invariant($"{(int)toLegacySampleBank(tempHitSample.Bank)},")); - writer.Write(FormattableString.Invariant($"{toLegacyCustomSampleBank(tempHitSample)},")); - writer.Write(FormattableString.Invariant($"{tempHitSample.Volume},")); - writer.Write(FormattableString.Invariant($"{(isTimingPoint ? '1' : '0')},")); - writer.Write(FormattableString.Invariant($"{(int)effectFlags}")); + return new LegacyControlPointProperties + { + SliderVelocity = difficultyPoint.SliderVelocity, + TimingSignature = timingPoint.TimeSignature.Numerator, + SampleBank = updateSampleBank ? (int)toLegacySampleBank(tempHitSample.Bank) : lastControlPointProperties.SampleBank, + // Inherit the previous custom sample bank if the current custom sample bank is not set + CustomSampleBank = customSampleBank >= 0 ? customSampleBank : lastControlPointProperties.CustomSampleBank, + SampleVolume = tempHitSample.Volume, + EffectFlags = effectFlags + }; + } + + void outputControlPointAt(LegacyControlPointProperties controlPoint, bool isTimingPoint) + { + writer.Write(FormattableString.Invariant($"{controlPoint.TimingSignature.ToString(CultureInfo.InvariantCulture)},")); + writer.Write(FormattableString.Invariant($"{controlPoint.SampleBank.ToString(CultureInfo.InvariantCulture)},")); + writer.Write(FormattableString.Invariant($"{controlPoint.CustomSampleBank.ToString(CultureInfo.InvariantCulture)},")); + writer.Write(FormattableString.Invariant($"{controlPoint.SampleVolume.ToString(CultureInfo.InvariantCulture)},")); + writer.Write(FormattableString.Invariant($"{(isTimingPoint ? "1" : "0")},")); + writer.Write(FormattableString.Invariant($"{((int)controlPoint.EffectFlags).ToString(CultureInfo.InvariantCulture)}")); writer.WriteLine(); } @@ -249,7 +271,10 @@ namespace osu.Game.Beatmaps.Formats yield break; foreach (var hitObject in hitObjects) - yield return hitObject.DifficultyControlPoint; + { + if (hitObject is IHasSliderVelocity hasSliderVelocity) + yield return new DifficultyControlPoint { Time = hitObject.StartTime, SliderVelocity = hasSliderVelocity.SliderVelocity }; + } } void extractDifficultyControlPoints(IEnumerable hitObjects) @@ -268,7 +293,15 @@ namespace osu.Game.Beatmaps.Formats { foreach (var hitObject in hitObjects) { - yield return hitObject.SampleControlPoint; + if (hitObject.Samples.Count > 0) + { + int volume = hitObject.Samples.Max(o => o.Volume); + int customIndex = hitObject.Samples.Any(o => o is ConvertHitObjectParser.LegacyHitSampleInfo) + ? hitObject.Samples.OfType().Max(o => o.CustomSampleBank) + : -1; + + yield return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = hitObject.GetEndTime(), SampleVolume = volume, CustomSampleBank = customIndex }; + } foreach (var nested in collectSampleControlPoints(hitObject.NestedHitObjects)) yield return nested; @@ -466,16 +499,16 @@ namespace osu.Game.Beatmaps.Formats if (curveData != null) { - for (int i = 0; i < curveData.NodeSamples.Count; i++) + for (int i = 0; i < curveData.SpanCount() + 1; i++) { - writer.Write(FormattableString.Invariant($"{(int)toLegacyHitSoundType(curveData.NodeSamples[i])}")); - writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); + writer.Write(FormattableString.Invariant($"{(i < curveData.NodeSamples.Count ? (int)toLegacyHitSoundType(curveData.NodeSamples[i]) : 0)}")); + writer.Write(i != curveData.SpanCount() ? "|" : ","); } - for (int i = 0; i < curveData.NodeSamples.Count; i++) + for (int i = 0; i < curveData.SpanCount() + 1; i++) { - writer.Write(getSampleBank(curveData.NodeSamples[i], true)); - writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); + writer.Write(i < curveData.NodeSamples.Count ? getSampleBank(curveData.NodeSamples[i], true) : "0:0"); + writer.Write(i != curveData.SpanCount() ? "|" : ","); } } } @@ -506,10 +539,18 @@ namespace osu.Game.Beatmaps.Formats if (!banksOnly) { - string customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name))); + int customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name))); string sampleFilename = samples.FirstOrDefault(s => string.IsNullOrEmpty(s.Name))?.LookupNames.First() ?? string.Empty; int volume = samples.FirstOrDefault()?.Volume ?? 100; + // We want to ignore custom sample banks and volume when not encoding to the mania game mode, + // because they cause unexpected results in the editor and are already satisfied by the control points. + if (onlineRulesetID != 3) + { + customSampleBank = 0; + volume = 0; + } + sb.Append(':'); sb.Append(FormattableString.Invariant($"{customSampleBank}:")); sb.Append(FormattableString.Invariant($"{volume}:")); @@ -548,13 +589,13 @@ namespace osu.Game.Beatmaps.Formats { switch (sampleBank?.ToLowerInvariant()) { - case "normal": + case HitSampleInfo.BANK_NORMAL: return LegacySampleBank.Normal; - case "soft": + case HitSampleInfo.BANK_SOFT: return LegacySampleBank.Soft; - case "drum": + case HitSampleInfo.BANK_DRUM: return LegacySampleBank.Drum; default: @@ -562,12 +603,30 @@ namespace osu.Game.Beatmaps.Formats } } - private string toLegacyCustomSampleBank(HitSampleInfo hitSampleInfo) + private int toLegacyCustomSampleBank(HitSampleInfo hitSampleInfo) { if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy) - return legacy.CustomSampleBank.ToString(CultureInfo.InvariantCulture); + return legacy.CustomSampleBank; - return "0"; + return 0; + } + + private struct LegacyControlPointProperties + { + internal double SliderVelocity { get; set; } + internal int TimingSignature { get; init; } + internal int SampleBank { get; init; } + internal int CustomSampleBank { get; init; } + internal int SampleVolume { get; init; } + internal LegacyEffectFlags EffectFlags { get; init; } + + internal bool IsRedundant(LegacyControlPointProperties other) => + SliderVelocity == other.SliderVelocity && + TimingSignature == other.TimingSignature && + SampleBank == other.SampleBank && + CustomSampleBank == other.CustomSampleBank && + SampleVolume == other.SampleVolume && + EffectFlags == other.EffectFlags; } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 704756e3dd..23440b8a1d 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -226,12 +226,16 @@ namespace osu.Game.Beatmaps.Formats public override HitSampleInfo ApplyTo(HitSampleInfo hitSampleInfo) { - var baseInfo = base.ApplyTo(hitSampleInfo); + if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy) + { + return legacy.With( + newCustomSampleBank: legacy.CustomSampleBank > 0 ? legacy.CustomSampleBank : CustomSampleBank, + newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume, + newBank: legacy.BankSpecified ? legacy.Bank : SampleBank + ); + } - if (baseInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) - return legacy.With(newCustomSampleBank: CustomSampleBank); - - return baseInfo; + return base.ApplyTo(hitSampleInfo); } public override bool IsRedundant(ControlPoint? existing) diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index 44dbb3cc9f..df5d3edb55 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -109,6 +109,14 @@ namespace osu.Game.Beatmaps.Formats int offset = Parsing.ParseInt(split[1]); string path = CleanFilename(split[2]); + // See handling in LegacyBeatmapDecoder for the special case where a video type is used but + // the file extension is not a valid video. + // + // This avoids potential weird crashes when ffmpeg attempts to parse an image file as a video + // (see https://github.com/ppy/osu/issues/22829#issuecomment-1465552451). + if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(path).ToLowerInvariant())) + break; + storyboard.GetLayer("Video").Add(new StoryboardVideo(path, offset)); break; } @@ -276,7 +284,8 @@ namespace osu.Game.Beatmaps.Formats switch (type) { case "A": - timelineGroup?.BlendingParameters.Add(easing, startTime, endTime, BlendingParameters.Additive, startTime == endTime ? BlendingParameters.Additive : BlendingParameters.Inherit); + timelineGroup?.BlendingParameters.Add(easing, startTime, endTime, BlendingParameters.Additive, + startTime == endTime ? BlendingParameters.Additive : BlendingParameters.Inherit); break; case "H": diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index f6771f7adf..9dc3084cb5 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -104,6 +104,19 @@ namespace osu.Game.Beatmaps } } + /// + /// Find the total milliseconds between the first and last hittable objects. + /// + /// + /// This is cached to , so using that is preferable when available. + /// + public static double CalculatePlayableLength(this IBeatmap beatmap) => CalculatePlayableLength(beatmap.HitObjects); + + /// + /// Find the timestamps in milliseconds of the start and end of the playable region. + /// + public static (double start, double end) CalculatePlayableBounds(this IBeatmap beatmap) => CalculatePlayableBounds(beatmap.HitObjects); + /// /// Find the absolute end time of the latest in a beatmap. Will throw if beatmap contains no objects. /// @@ -114,5 +127,36 @@ namespace osu.Game.Beatmaps /// It's not super efficient so calls should be kept to a minimum. /// public static double GetLastObjectTime(this IBeatmap beatmap) => beatmap.HitObjects.Max(h => h.GetEndTime()); + + #region Helper methods + + /// + /// Find the total milliseconds between the first and last hittable objects. + /// + /// + /// This is cached to , so using that is preferable when available. + /// + public static double CalculatePlayableLength(IEnumerable objects) + { + (double start, double end) = CalculatePlayableBounds(objects); + + return end - start; + } + + /// + /// Find the timestamps in milliseconds of the start and end of the playable region. + /// + public static (double start, double end) CalculatePlayableBounds(IEnumerable objects) + { + if (!objects.Any()) + return (0, 0); + + double lastObjectTime = objects.Max(o => o.GetEndTime()); + double firstObjectTime = objects.First().StartTime; + + return (firstObjectTime, lastObjectTime); + } + + #endregion } } diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs index dad9bbbd0b..78234a9dd9 100644 --- a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs @@ -34,7 +34,8 @@ namespace osu.Game.Beatmaps float ApproachRate { get; } /// - /// The slider multiplier of the associated beatmap. + /// The base slider velocity of the associated beatmap. + /// This was known as "SliderMultiplier" in the .osu format and stable editor. /// double SliderMultiplier { get; } diff --git a/osu.Game/Beatmaps/MetadataLookupScope.cs b/osu.Game/Beatmaps/MetadataLookupScope.cs new file mode 100644 index 0000000000..e1fbedc26a --- /dev/null +++ b/osu.Game/Beatmaps/MetadataLookupScope.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. + +namespace osu.Game.Beatmaps +{ + /// + /// Determines which sources (if any at all) should be queried in which order for a beatmap's metadata. + /// + public enum MetadataLookupScope + { + /// + /// Do not attempt to look up the beatmap metadata either in the local cache or online. + /// + None, + + /// + /// Try the local metadata cache first before querying online sources. + /// + LocalCacheFirst, + + /// + /// Query online sources immediately. + /// + OnlineFirst + } +} diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index ab790617bb..59a71fd80c 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -16,7 +16,6 @@ using osu.Framework.Audio.Track; using osu.Framework.Extensions; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; -using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -25,7 +24,6 @@ using osu.Game.Storyboards; namespace osu.Game.Beatmaps { - [ExcludeFromDynamicCompile] public abstract class WorkingBeatmap : IWorkingBeatmap { public readonly BeatmapInfo BeatmapInfo; diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 76a31a6f78..ef843909d8 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -17,7 +17,6 @@ using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Statistics; -using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.IO; @@ -121,7 +120,6 @@ namespace osu.Game.Beatmaps #endregion - [ExcludeFromDynamicCompile] private class BeatmapManagerWorkingBeatmap : WorkingBeatmap { [NotNull] diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 23156b1ad5..0ab0ff520d 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -86,6 +86,7 @@ namespace osu.Game.Collections RelativeSizeAxes = Axes.Both, Size = Vector2.One, CornerRadius = item_height / 2, + CommitOnFocusLost = true, PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection" }, } diff --git a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs index dda30c1d00..33329002a9 100644 --- a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs +++ b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs @@ -4,11 +4,9 @@ #nullable disable using osu.Framework.Platform; -using osu.Framework.Testing; namespace osu.Game.Configuration { - [ExcludeFromDynamicCompile] public class DevelopmentOsuConfigManager : OsuConfigManager { protected override string Filename => base.Filename.Replace(".ini", ".dev.ini"); diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 70ad6bfc96..365ad37f4c 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -12,7 +12,6 @@ using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Framework.Platform; -using osu.Framework.Testing; using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Input; using osu.Game.Input.Bindings; @@ -26,7 +25,6 @@ using osu.Game.Skinning; namespace osu.Game.Configuration { - [ExcludeFromDynamicCompile] public class OsuConfigManager : IniConfigManager, IGameplaySettings { public OsuConfigManager(Storage storage) @@ -157,6 +155,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Scaling, ScalingMode.Off); SetDefault(OsuSetting.SafeAreaConsiderations, true); + SetDefault(OsuSetting.ScalingBackgroundDim, 0.9f, 0.5f, 1f); SetDefault(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f); SetDefault(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f); @@ -364,6 +363,7 @@ namespace osu.Game.Configuration ScalingPositionY, ScalingSizeX, ScalingSizeY, + ScalingBackgroundDim, UIScale, IntroSequence, NotifyOnUsernameMentioned, diff --git a/osu.Game/Database/LegacyArchiveExporter.cs b/osu.Game/Database/LegacyArchiveExporter.cs new file mode 100644 index 0000000000..7689ffc13d --- /dev/null +++ b/osu.Game/Database/LegacyArchiveExporter.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Linq; +using System.Threading; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Extensions; +using osu.Game.Overlays.Notifications; +using Realms; +using SharpCompress.Common; +using SharpCompress.Writers; +using SharpCompress.Writers.Zip; +using Logger = osu.Framework.Logging.Logger; + +namespace osu.Game.Database +{ + /// + /// Handles the common scenario of exporting a model to a zip-based archive, usually with a custom file extension. + /// + public abstract class LegacyArchiveExporter : LegacyExporter + where TModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey + { + protected LegacyArchiveExporter(Storage storage) + : base(storage) + { + } + + public override void ExportToStream(TModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default) + { + using (var writer = new ZipWriter(outputStream, new ZipWriterOptions(CompressionType.Deflate))) + { + int i = 0; + int fileCount = model.Files.Count(); + bool anyFileMissing = false; + + foreach (var file in model.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + + using (var stream = UserFileStorage.GetStream(file.File.GetStoragePath())) + { + if (stream == null) + { + Logger.Log($"File {file.Filename} is missing in local storage and will not be included in the export", LoggingTarget.Database); + anyFileMissing = true; + continue; + } + + writer.Write(file.Filename, stream); + } + + i++; + + if (notification != null) + { + notification.Progress = (float)i / fileCount; + } + } + + if (anyFileMissing) + { + Logger.Log("Some files are missing in local storage and will not be included in the export", LoggingTarget.Database, LogLevel.Error); + } + } + } + } +} diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index d064b9ed58..4ee8c0636e 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -1,20 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Platform; using osu.Game.Beatmaps; namespace osu.Game.Database { - public class LegacyBeatmapExporter : LegacyExporter + public class LegacyBeatmapExporter : LegacyArchiveExporter { - protected override string FileExtension => ".osz"; - public LegacyBeatmapExporter(Storage storage) : base(storage) { } + + protected override string FileExtension => @".osz"; } } diff --git a/osu.Game/Database/LegacyExporter.cs b/osu.Game/Database/LegacyExporter.cs index 8da285daa3..f9164e34cd 100644 --- a/osu.Game/Database/LegacyExporter.cs +++ b/osu.Game/Database/LegacyExporter.cs @@ -1,24 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Platform; using osu.Game.Extensions; +using osu.Game.Overlays.Notifications; using osu.Game.Utils; -using SharpCompress.Archives.Zip; +using Realms; namespace osu.Game.Database { /// - /// A class which handles exporting legacy user data of a single type from osu-stable. + /// Handles exporting models to files for sharing / consumption outside the game. /// public abstract class LegacyExporter - where TModel : class, IHasNamedFiles + where TModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey { /// /// Max length of filename (including extension). @@ -39,55 +40,93 @@ namespace osu.Game.Database protected abstract string FileExtension { get; } protected readonly Storage UserFileStorage; - private readonly Storage exportStorage; + public Action? PostNotification { get; set; } + protected LegacyExporter(Storage storage) { exportStorage = storage.GetStorageForDirectory(@"exports"); UserFileStorage = storage.GetStorageForDirectory(@"files"); } + /// + /// Returns the baseline name of the file to which the will be exported. + /// + /// + /// The name of the file will be run through to eliminate characters + /// which are not permitted by various filesystems. + /// + /// The item being exported. protected virtual string GetFilename(TModel item) => item.GetDisplayString(); /// - /// Exports an item to a legacy (.zip based) package. + /// Exports a model to the default export location. + /// This will create a notification tracking the progress of the export, visible to the user. /// - /// The item to export. - public void Export(TModel item) + /// The model to export. + /// A cancellation token. + public async Task ExportAsync(Live model, CancellationToken cancellationToken = default) { - string itemFilename = GetFilename(item).GetValidFilename(); + string itemFilename = model.PerformRead(s => GetFilename(s).GetValidFilename()); if (itemFilename.Length > MAX_FILENAME_LENGTH - FileExtension.Length) itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - FileExtension.Length); - IEnumerable existingExports = - exportStorage - .GetFiles(string.Empty, $"{itemFilename}*{FileExtension}") - .Concat(exportStorage.GetDirectories(string.Empty)); + IEnumerable existingExports = exportStorage + .GetFiles(string.Empty, $"{itemFilename}*{FileExtension}") + .Concat(exportStorage.GetDirectories(string.Empty)); string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}"); - using (var stream = exportStorage.CreateFileSafely(filename)) - ExportModelTo(item, stream); + ProgressNotification notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = $"Exporting {itemFilename}...", + }; - exportStorage.PresentFileExternally(filename); + PostNotification?.Invoke(notification); + + using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, notification.CancellationToken); + + try + { + using (var stream = exportStorage.CreateFileSafely(filename)) + { + await ExportToStreamAsync(model, stream, notification, linkedSource.Token).ConfigureAwait(false); + } + } + catch + { + notification.State = ProgressNotificationState.Cancelled; + + // cleanup if export is failed or canceled. + exportStorage.Delete(filename); + throw; + } + + notification.CompletionText = $"Exported {itemFilename}! Click to view."; + notification.CompletionClickAction = () => exportStorage.PresentFileExternally(filename); + notification.State = ProgressNotificationState.Completed; } /// - /// Exports an item to the given output stream. + /// Exports a model to a provided stream. /// - /// The item to export. + /// The model to export. /// The output stream to export to. - public virtual void ExportModelTo(TModel model, Stream outputStream) - { - using (var archive = ZipArchive.Create()) - { - foreach (var file in model.Files) - archive.AddEntry(file.Filename, UserFileStorage.GetStream(file.File.GetStoragePath())); + /// An optional notification to be updated with export progress. + /// A cancellation token. + public Task ExportToStreamAsync(Live model, Stream outputStream, ProgressNotification? notification = null, CancellationToken cancellationToken = default) => + Task.Run(() => { model.PerformRead(s => ExportToStream(s, outputStream, notification, cancellationToken)); }, cancellationToken); - archive.SaveTo(outputStream); - } - } + /// + /// Exports a model to a provided stream. + /// + /// The model to export. + /// The output stream to export to. + /// An optional notification to be updated with export progress. + /// A cancellation token. + public abstract void ExportToStream(TModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default); } } diff --git a/osu.Game/Database/LegacyScoreExporter.cs b/osu.Game/Database/LegacyScoreExporter.cs index 01f9afdc86..690070af85 100644 --- a/osu.Game/Database/LegacyScoreExporter.cs +++ b/osu.Game/Database/LegacyScoreExporter.cs @@ -1,20 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.IO; using System.Linq; +using System.Threading; using osu.Framework.Platform; using osu.Game.Extensions; +using osu.Game.Overlays.Notifications; using osu.Game.Scoring; namespace osu.Game.Database { public class LegacyScoreExporter : LegacyExporter { - protected override string FileExtension => ".osr"; - public LegacyScoreExporter(Storage storage) : base(storage) { @@ -28,7 +26,9 @@ namespace osu.Game.Database return filename; } - public override void ExportModelTo(ScoreInfo model, Stream outputStream) + protected override string FileExtension => @".osr"; + + public override void ExportToStream(ScoreInfo model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default) { var file = model.Files.SingleOrDefault(); if (file == null) diff --git a/osu.Game/Database/LegacyScoreImporter.cs b/osu.Game/Database/LegacyScoreImporter.cs index f61241141e..b80a35f90a 100644 --- a/osu.Game/Database/LegacyScoreImporter.cs +++ b/osu.Game/Database/LegacyScoreImporter.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.IO; @@ -22,7 +20,7 @@ namespace osu.Game.Database return Enumerable.Empty(); return storage.GetFiles(ImportFromStablePath) - .Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) + .Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p).Equals(ext, StringComparison.OrdinalIgnoreCase))) .Select(path => storage.GetFullPath(path)); } diff --git a/osu.Game/Database/LegacySkinExporter.cs b/osu.Game/Database/LegacySkinExporter.cs index 1d5364fb8d..14a3907916 100644 --- a/osu.Game/Database/LegacySkinExporter.cs +++ b/osu.Game/Database/LegacySkinExporter.cs @@ -1,20 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Platform; using osu.Game.Skinning; namespace osu.Game.Database { - public class LegacySkinExporter : LegacyExporter + public class LegacySkinExporter : LegacyArchiveExporter { - protected override string FileExtension => ".osk"; - public LegacySkinExporter(Storage storage) : base(storage) { } + + protected override string FileExtension => @".osk"; } } diff --git a/osu.Game/Database/LegacySkinImporter.cs b/osu.Game/Database/LegacySkinImporter.cs index 42b2f2e1d8..2f05ccae45 100644 --- a/osu.Game/Database/LegacySkinImporter.cs +++ b/osu.Game/Database/LegacySkinImporter.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.Game.Skinning; namespace osu.Game.Database diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 831e328439..94108531e8 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -179,43 +179,9 @@ namespace osu.Game.Database applyFilenameSchemaSuffix(ref Filename); #endif - string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}"; - - // Attempt to recover a newer database version if available. - if (storage.Exists(newerVersionFilename)) - { - Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database); - attemptRecoverFromFile(newerVersionFilename); - } - - try - { - // This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date. - cleanupPendingDeletions(); - } - catch (Exception e) - { - // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022 - // This is the best way we can detect a schema version downgrade. - if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal)) - { - Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data."); - - // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about. - if (!storage.Exists(newerVersionFilename)) - createBackup(newerVersionFilename); - - storage.Delete(Filename); - } - else - { - Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); - createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); - storage.Delete(Filename); - } - - cleanupPendingDeletions(); - } + // `prepareFirstRealmAccess()` triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date. + using (var realm = prepareFirstRealmAccess()) + cleanupPendingDeletions(realm); } /// @@ -312,49 +278,93 @@ namespace osu.Game.Database Logger.Log(@"Recovery complete!", LoggingTarget.Database); } - private void cleanupPendingDeletions() + private Realm prepareFirstRealmAccess() { - using (var realm = getRealmInstance()) - using (var transaction = realm.BeginWrite()) + string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}"; + + // Attempt to recover a newer database version if available. + if (storage.Exists(newerVersionFilename)) { - var pendingDeleteScores = realm.All().Where(s => s.DeletePending); - - foreach (var score in pendingDeleteScores) - realm.Remove(score); - - var pendingDeleteSets = realm.All().Where(s => s.DeletePending); - - foreach (var beatmapSet in pendingDeleteSets) - { - foreach (var beatmap in beatmapSet.Beatmaps) - { - // Cascade delete related scores, else they will have a null beatmap against the model's spec. - foreach (var score in beatmap.Scores) - realm.Remove(score); - - realm.Remove(beatmap.Metadata); - realm.Remove(beatmap); - } - - realm.Remove(beatmapSet); - } - - var pendingDeleteSkins = realm.All().Where(s => s.DeletePending); - - foreach (var s in pendingDeleteSkins) - realm.Remove(s); - - var pendingDeletePresets = realm.All().Where(s => s.DeletePending); - - foreach (var s in pendingDeletePresets) - realm.Remove(s); - - transaction.Commit(); + Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database); + attemptRecoverFromFile(newerVersionFilename); } - // clean up files after dropping any pending deletions. - // in the future we may want to only do this when the game is idle, rather than on every startup. - new RealmFileStore(this, storage).Cleanup(); + try + { + return getRealmInstance(); + } + catch (Exception e) + { + // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022 + // This is the best way we can detect a schema version downgrade. + if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal)) + { + Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data."); + + // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about. + if (!storage.Exists(newerVersionFilename)) + createBackup(newerVersionFilename); + } + else + { + Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); + createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); + } + + storage.Delete(Filename); + return getRealmInstance(); + } + } + + private void cleanupPendingDeletions(Realm realm) + { + try + { + using (var transaction = realm.BeginWrite()) + { + var pendingDeleteScores = realm.All().Where(s => s.DeletePending); + + foreach (var score in pendingDeleteScores) + realm.Remove(score); + + var pendingDeleteSets = realm.All().Where(s => s.DeletePending); + + foreach (var beatmapSet in pendingDeleteSets) + { + foreach (var beatmap in beatmapSet.Beatmaps) + { + // Cascade delete related scores, else they will have a null beatmap against the model's spec. + foreach (var score in beatmap.Scores) + realm.Remove(score); + + realm.Remove(beatmap.Metadata); + realm.Remove(beatmap); + } + + realm.Remove(beatmapSet); + } + + var pendingDeleteSkins = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeleteSkins) + realm.Remove(s); + + var pendingDeletePresets = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeletePresets) + realm.Remove(s); + + transaction.Commit(); + } + + // clean up files after dropping any pending deletions. + // in the future we may want to only do this when the game is idle, rather than on every startup. + new RealmFileStore(this, storage).Cleanup(); + } + catch (Exception e) + { + Logger.Error(e, "Failed to clean up unused files. This is not critical but please report if it happens regularly."); + } } /// @@ -909,7 +919,7 @@ namespace osu.Game.Database int attempts = 10; - while (attempts-- > 0) + while (true) { try { @@ -927,6 +937,9 @@ namespace osu.Game.Database } catch (IOException) { + if (attempts-- <= 0) + throw; + // file may be locked during use. Thread.Sleep(500); } diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index f75d3be725..1da64d5be8 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -8,7 +8,6 @@ using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Testing; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Models; @@ -19,7 +18,6 @@ namespace osu.Game.Database /// /// Handles the storing of files to the file system (and database) backing. /// - [ExcludeFromDynamicCompile] public class RealmFileStore { private readonly RealmAccess realm; diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 28a715ca0b..0ee42c69d5 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -306,7 +306,7 @@ namespace osu.Game.Graphics.Backgrounds }; shader.Bind(); - shader.BindUniformBlock("m_BorderData", borderDataBuffer); + shader.BindUniformBlock(@"m_BorderData", borderDataBuffer); foreach (TriangleParticle particle in parts) { diff --git a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs index 6a34fefa3a..750e96440d 100644 --- a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs +++ b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs @@ -249,7 +249,7 @@ namespace osu.Game.Graphics.Backgrounds }; shader.Bind(); - shader.BindUniformBlock("m_BorderData", borderDataBuffer); + shader.BindUniformBlock(@"m_BorderData", borderDataBuffer); Vector2 relativeSize = Vector2.Divide(triangleSize, size); diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index fb5c3e3b60..c47aba2f0c 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -15,6 +15,7 @@ using osu.Game.Configuration; using osu.Game.Screens; using osu.Game.Screens.Backgrounds; using osuTK; +using osuTK.Graphics; namespace osu.Game.Graphics.Containers { @@ -46,6 +47,8 @@ namespace osu.Game.Graphics.Containers private BackgroundScreenStack backgroundStack; + private Bindable scalingMenuBackgroundDim; + private RectangleF? customRect; private bool customRectIsRelativePosition; @@ -138,6 +141,9 @@ namespace osu.Game.Graphics.Containers safeAreaPadding = safeArea.SafeAreaPadding.GetBoundCopy(); safeAreaPadding.BindValueChanged(_ => Scheduler.AddOnce(updateSize)); + + scalingMenuBackgroundDim = config.GetBindable(OsuSetting.ScalingBackgroundDim); + scalingMenuBackgroundDim.ValueChanged += _ => Scheduler.AddOnce(updateSize); } protected override void LoadComplete() @@ -148,7 +154,9 @@ namespace osu.Game.Graphics.Containers sizableContainer.FinishTransforms(); } - private bool requiresBackgroundVisible => (scalingMode.Value == ScalingMode.Everything || scalingMode.Value == ScalingMode.ExcludeOverlays) && (sizeX.Value != 1 || sizeY.Value != 1); + private bool requiresBackgroundVisible => (scalingMode.Value == ScalingMode.Everything || scalingMode.Value == ScalingMode.ExcludeOverlays) + && (sizeX.Value != 1 || sizeY.Value != 1) + && scalingMenuBackgroundDim.Value < 1; private void updateSize() { @@ -161,8 +169,8 @@ namespace osu.Game.Graphics.Containers { AddInternal(backgroundStack = new BackgroundScreenStack { - Colour = OsuColour.Gray(0.1f), Alpha = 0, + Colour = Color4.Black, Depth = float.MaxValue }); @@ -170,6 +178,7 @@ namespace osu.Game.Graphics.Containers } backgroundStack.FadeIn(TRANSITION_DURATION); + backgroundStack.FadeColour(OsuColour.Gray(1.0f - scalingMenuBackgroundDim.Value), TRANSITION_DURATION, Easing.OutQuint); } else backgroundStack?.FadeOut(TRANSITION_DURATION); diff --git a/osu.Game/Graphics/Containers/WaveContainer.cs b/osu.Game/Graphics/Containers/WaveContainer.cs index 952ef3f182..05a666721a 100644 --- a/osu.Game/Graphics/Containers/WaveContainer.cs +++ b/osu.Game/Graphics/Containers/WaveContainer.cs @@ -17,6 +17,7 @@ namespace osu.Game.Graphics.Containers { public const float APPEAR_DURATION = 800; public const float DISAPPEAR_DURATION = 500; + public const float SHADOW_OPACITY = 0.2f; private const Easing easing_show = Easing.OutSine; private const Easing easing_hide = Easing.InSine; diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index d799e82bc9..82f89d6889 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -19,6 +19,7 @@ using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Input.Bindings; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using SixLabors.ImageSharp; @@ -69,7 +70,7 @@ namespace osu.Game.Graphics { case GlobalAction.TakeScreenshot: shutter.Play(); - TakeScreenshotAsync(); + TakeScreenshotAsync().FireAndForget(); return true; } @@ -86,70 +87,75 @@ namespace osu.Game.Graphics { Interlocked.Increment(ref screenShotTasks); - if (!captureMenuCursor.Value) + try { - cursorVisibility.Value = false; - - // We need to wait for at most 3 draw nodes to be drawn, following which we can be assured at least one DrawNode has been generated/drawn with the set value - const int frames_to_wait = 3; - - int framesWaited = 0; - - using (var framesWaitedEvent = new ManualResetEventSlim(false)) + if (!captureMenuCursor.Value) { - ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() => + cursorVisibility.Value = false; + + // We need to wait for at most 3 draw nodes to be drawn, following which we can be assured at least one DrawNode has been generated/drawn with the set value + const int frames_to_wait = 3; + + int framesWaited = 0; + + using (var framesWaitedEvent = new ManualResetEventSlim(false)) { - if (framesWaited++ >= frames_to_wait) - // ReSharper disable once AccessToDisposedClosure - framesWaitedEvent.Set(); - }, 10, true); + ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() => + { + if (framesWaited++ >= frames_to_wait) + // ReSharper disable once AccessToDisposedClosure + framesWaitedEvent.Set(); + }, 10, true); - if (!framesWaitedEvent.Wait(1000)) - throw new TimeoutException("Screenshot data did not arrive in a timely fashion"); + if (!framesWaitedEvent.Wait(1000)) + throw new TimeoutException("Screenshot data did not arrive in a timely fashion"); - waitDelegate.Cancel(); + waitDelegate.Cancel(); + } + } + + using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false)) + { + host.GetClipboard()?.SetImage(image); + + (string filename, var stream) = getWritableStream(); + + if (filename == null) return; + + using (stream) + { + switch (screenshotFormat.Value) + { + case ScreenshotFormat.Png: + await image.SaveAsPngAsync(stream).ConfigureAwait(false); + break; + + case ScreenshotFormat.Jpg: + const int jpeg_quality = 92; + + await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); + break; + + default: + throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}."); + } + } + + notificationOverlay.Post(new SimpleNotification + { + Text = $"Screenshot {filename} saved!", + Activated = () => + { + storage.PresentFileExternally(filename); + return true; + } + }); } } - - using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false)) + finally { - if (Interlocked.Decrement(ref screenShotTasks) == 0 && cursorVisibility.Value == false) + if (Interlocked.Decrement(ref screenShotTasks) == 0) cursorVisibility.Value = true; - - host.GetClipboard()?.SetImage(image); - - (string filename, var stream) = getWritableStream(); - - if (filename == null) return; - - using (stream) - { - switch (screenshotFormat.Value) - { - case ScreenshotFormat.Png: - await image.SaveAsPngAsync(stream).ConfigureAwait(false); - break; - - case ScreenshotFormat.Jpg: - const int jpeg_quality = 92; - - await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); - break; - - default: - throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}."); - } - } - - notificationOverlay.Post(new SimpleNotification - { - Text = $"Screenshot {filename} saved!", - Activated = () => - { - storage.PresentFileExternally(filename); - return true; - } - }); } }); diff --git a/osu.Game/Graphics/Sprites/LogoAnimation.cs b/osu.Game/Graphics/Sprites/LogoAnimation.cs index 220f57e9fa..f02017dc57 100644 --- a/osu.Game/Graphics/Sprites/LogoAnimation.cs +++ b/osu.Game/Graphics/Sprites/LogoAnimation.cs @@ -3,13 +3,18 @@ #nullable disable +using System; using System.Runtime.InteropServices; using osu.Framework.Allocation; using osu.Framework.Graphics; 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; +using osuTK.Graphics; +using osuTK.Graphics.ES30; namespace osu.Game.Graphics.Sprites { @@ -18,7 +23,7 @@ namespace osu.Game.Graphics.Sprites [BackgroundDependencyLoader] private void load(ShaderManager shaders) { - TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); + TextureShader = shaders.Load(@"LogoAnimation", @"LogoAnimation"); } private float animationProgress; @@ -43,11 +48,22 @@ namespace osu.Game.Graphics.Sprites { private LogoAnimation source => (LogoAnimation)Source; + private readonly Action addVertexAction; + private float progress; public LogoAnimationDrawNode(LogoAnimation source) : base(source) { + addVertexAction = v => + { + animationVertexBatch!.Add(new LogoAnimationVertex + { + Position = v.Position, + Colour = v.Colour, + TexturePosition = v.TexturePosition, + }); + }; } public override void ApplyState() @@ -58,15 +74,34 @@ namespace osu.Game.Graphics.Sprites } private IUniformBuffer animationDataBuffer; + private IVertexBatch animationVertexBatch; + + protected override void BindUniformResources(IShader shader, IRenderer renderer) + { + base.BindUniformResources(shader, renderer); + + animationDataBuffer ??= renderer.CreateUniformBuffer(); + animationVertexBatch ??= renderer.CreateQuadBatch(1, 2); + + animationDataBuffer.Data = animationDataBuffer.Data with { Progress = progress }; + + shader.BindUniformBlock(@"m_AnimationData", animationDataBuffer); + } protected override void Blit(IRenderer renderer) { - animationDataBuffer ??= renderer.CreateUniformBuffer(); - animationDataBuffer.Data = animationDataBuffer.Data with { Progress = progress }; - - TextureShader.BindUniformBlock("m_AnimationData", animationDataBuffer); + if (DrawRectangle.Width == 0 || DrawRectangle.Height == 0) + return; base.Blit(renderer); + + renderer.DrawQuad( + Texture, + ScreenSpaceDrawQuad, + DrawColourInfo.Colour, + inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + textureCoords: TextureCoords, + vertexAction: addVertexAction); } protected override bool CanDrawOpaqueInterior => false; @@ -83,6 +118,24 @@ namespace osu.Game.Graphics.Sprites public UniformFloat Progress; private readonly UniformPadding12 pad1; } + + [StructLayout(LayoutKind.Sequential)] + private struct LogoAnimationVertex : IEquatable, IVertex + { + [VertexMember(2, VertexAttribPointerType.Float)] + public Vector2 Position; + + [VertexMember(4, VertexAttribPointerType.Float)] + public Color4 Colour; + + [VertexMember(2, VertexAttribPointerType.Float)] + public Vector2 TexturePosition; + + public readonly bool Equals(LogoAnimationVertex other) => + Position.Equals(other.Position) + && TexturePosition.Equals(other.TexturePosition) + && Colour.Equals(other.Colour); + } } } } diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index ad02e3b2ab..eb046932e6 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -77,10 +77,10 @@ namespace osu.Game.Graphics.UserInterface private void updateState() { - hoverClickSounds.Enabled.Value = !Item.Action.Disabled; - Alpha = Item.Action.Disabled ? 0.2f : 1; + hoverClickSounds.Enabled.Value = IsActionable; + Alpha = IsActionable ? 1 : 0.2f; - if (IsHovered && !Item.Action.Disabled) + if (IsHovered && IsActionable) { text.BoldText.FadeIn(transition_length, Easing.OutQuint); text.NormalText.FadeOut(transition_length, Easing.OutQuint); diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 0c36d73085..e5f5f97eb7 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -89,15 +89,19 @@ namespace osu.Game.Graphics.UserInterface double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo); - if (DisplayAsPercentage) - return floatValue.ToString("0%"); - decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits); // Find the number of significant digits (we could have less than 5 after normalize()) int significantDigits = FormatUtils.FindPrecision(decimalPrecision); - return floatValue.ToString($"N{significantDigits}"); + 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}")}"; } /// diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index fa58ae27f2..c9e1f74917 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -49,11 +49,10 @@ namespace osu.Game.Graphics.UserInterface private const float transition_length = 500; private Sample sampleChecked; private Sample sampleUnchecked; + private readonly SpriteIcon icon; public OsuTabControlCheckbox() { - SpriteIcon icon; - AutoSizeAxes = Axes.Both; Children = new Drawable[] @@ -85,14 +84,6 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.BottomLeft, } }; - - Current.ValueChanged += selected => - { - icon.Icon = selected.NewValue ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.Circle; - text.Font = text.Font.With(weight: selected.NewValue ? FontWeight.Bold : FontWeight.Medium); - - updateFade(); - }; } [BackgroundDependencyLoader] @@ -105,6 +96,19 @@ namespace osu.Game.Graphics.UserInterface sampleUnchecked = audio.Samples.Get(@"UI/check-off"); } + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(selected => + { + icon.Icon = selected.NewValue ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.Circle; + text.Font = text.Font.With(weight: selected.NewValue ? FontWeight.Bold : FontWeight.Medium); + + updateFade(); + }, true); + } + protected override bool OnHover(HoverEvent e) { updateFade(); diff --git a/osu.Game/Graphics/UserInterface/TimeSlider.cs b/osu.Game/Graphics/UserInterface/TimeSlider.cs index e4058827f3..e6e7ae9305 100644 --- a/osu.Game/Graphics/UserInterface/TimeSlider.cs +++ b/osu.Game/Graphics/UserInterface/TimeSlider.cs @@ -12,6 +12,6 @@ namespace osu.Game.Graphics.UserInterface /// public partial class TimeSlider : RoundedSliderBar { - public override LocalisableString TooltipText => $"{Current.Value:N0} ms"; + public override LocalisableString TooltipText => $"{base.TooltipText} ms"; } } diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs index 9b7087ce6d..16bad5785f 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs @@ -152,7 +152,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 [BackgroundDependencyLoader(true)] private void load(OverlayColourProvider? colourProvider, OsuColour osuColour) { - background.Colour = colourProvider?.Background5 ?? Color4Extensions.FromHex(@"1c2125"); + background.Colour = colourProvider?.Background4 ?? Color4Extensions.FromHex(@"1c2125"); descriptionText.Colour = osuColour.Yellow; } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index 37e15c6127..7097102335 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -70,7 +70,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { get { - if (OsuGameBase.VIDEO_EXTENSIONS.Contains(File.Extension)) + if (OsuGameBase.VIDEO_EXTENSIONS.Contains(File.Extension.ToLowerInvariant())) return FontAwesome.Regular.FileVideo; switch (File.Extension) diff --git a/osu.Game/Graphics/UserInterfaceV2/ReportPopover.cs b/osu.Game/Graphics/UserInterfaceV2/ReportPopover.cs new file mode 100644 index 0000000000..7b3c32d60d --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/ReportPopover.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + /// + /// A generic popover for sending an online report about something. + /// + /// An enumeration type with all valid reasons for the report. + public abstract partial class ReportPopover : OsuPopover + where TReportReason : struct, Enum + { + /// + /// The action to run when the report is finalised. + /// The arguments to this action are: the reason for the report, and an optional additional comment. + /// + public Action? Action; + + private OsuEnumDropdown reasonDropdown = null!; + private OsuTextBox commentsTextBox = null!; + private RoundedButton submitButton = null!; + + private readonly LocalisableString header; + + /// + /// Creates a new . + /// + /// The text to display in the header of the popover. + protected ReportPopover(LocalisableString headerString) + { + header = headerString; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Child = new ReverseChildIDFillFlowContainer + { + Direction = FillDirection.Vertical, + Width = 500, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Children = new Drawable[] + { + new SpriteIcon + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Icon = FontAwesome.Solid.ExclamationTriangle, + Size = new Vector2(36), + }, + new OsuSpriteText + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Text = header, + Font = OsuFont.Torus.With(size: 25), + Margin = new MarginPadding { Bottom = 10 } + }, + new OsuSpriteText + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Text = UsersStrings.ReportReason, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 40, + Child = reasonDropdown = new OsuEnumDropdown + { + RelativeSizeAxes = Axes.X + } + }, + new OsuSpriteText + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Text = UsersStrings.ReportComments, + }, + commentsTextBox = new OsuTextBox + { + RelativeSizeAxes = Axes.X, + PlaceholderText = UsersStrings.ReportPlaceholder, + }, + submitButton = new RoundedButton + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Width = 200, + BackgroundColour = colours.Red3, + Text = UsersStrings.ReportActionsSend, + Action = () => + { + Action?.Invoke(reasonDropdown.Current.Value, commentsTextBox.Text); + this.HidePopover(); + }, + Margin = new MarginPadding { Bottom = 5, Top = 10 }, + } + } + }; + + commentsTextBox.Current.BindValueChanged(_ => updateStatus()); + + reasonDropdown.Current.BindValueChanged(_ => updateStatus()); + + updateStatus(); + } + + private void updateStatus() + { + submitButton.Enabled.Value = !string.IsNullOrWhiteSpace(commentsTextBox.Current.Value) || !IsCommentRequired(reasonDropdown.Current.Value); + } + + /// + /// Determines whether an additional comment is required for submitting the report with the supplied . + /// + protected virtual bool IsCommentRequired(TReportReason reason) => true; + } +} diff --git a/osu.Game/Input/OsuUserInputManager.cs b/osu.Game/Input/OsuUserInputManager.cs index ab43497156..c205636ab9 100644 --- a/osu.Game/Input/OsuUserInputManager.cs +++ b/osu.Game/Input/OsuUserInputManager.cs @@ -3,6 +3,7 @@ #nullable disable +using osu.Framework.Bindables; using osu.Framework.Input; using osuTK.Input; @@ -10,6 +11,10 @@ namespace osu.Game.Input { public partial class OsuUserInputManager : UserInputManager { + protected override bool AllowRightClickFromLongTouch => !LocalUserPlaying.Value; + + public readonly BindableBool LocalUserPlaying = new BindableBool(); + internal OsuUserInputManager() { } diff --git a/osu.Game/Localisation/EditorSetupStrings.cs b/osu.Game/Localisation/EditorSetupStrings.cs index 4ddacf2c5b..401411365b 100644 --- a/osu.Game/Localisation/EditorSetupStrings.cs +++ b/osu.Game/Localisation/EditorSetupStrings.cs @@ -42,8 +42,7 @@ namespace osu.Game.Localisation /// /// "If enabled, an "Are you ready? 3, 2, 1, GO!" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so." /// - public static LocalisableString CountdownDescription => new TranslatableString(getKey(@"countdown_description"), - @"If enabled, an ""Are you ready? 3, 2, 1, GO!"" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so."); + public static LocalisableString CountdownDescription => new TranslatableString(getKey(@"countdown_description"), @"If enabled, an ""Are you ready? 3, 2, 1, GO!"" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so."); /// /// "Countdown speed" @@ -53,8 +52,7 @@ namespace osu.Game.Localisation /// /// "If the countdown sounds off-time, use this to make it appear one or more beats early." /// - public static LocalisableString CountdownOffsetDescription => - new TranslatableString(getKey(@"countdown_offset_description"), @"If the countdown sounds off-time, use this to make it appear one or more beats early."); + public static LocalisableString CountdownOffsetDescription => new TranslatableString(getKey(@"countdown_offset_description"), @"If the countdown sounds off-time, use this to make it appear one or more beats early."); /// /// "Countdown offset" @@ -69,8 +67,7 @@ namespace osu.Game.Localisation /// /// "Allows storyboards to use the full screen space, rather than be confined to a 4:3 area." /// - public static LocalisableString WidescreenSupportDescription => - new TranslatableString(getKey(@"widescreen_support_description"), @"Allows storyboards to use the full screen space, rather than be confined to a 4:3 area."); + public static LocalisableString WidescreenSupportDescription => new TranslatableString(getKey(@"widescreen_support_description"), @"Allows storyboards to use the full screen space, rather than be confined to a 4:3 area."); /// /// "Epilepsy warning" @@ -80,8 +77,7 @@ namespace osu.Game.Localisation /// /// "Recommended if the storyboard or video contain scenes with rapidly flashing colours." /// - public static LocalisableString EpilepsyWarningDescription => - new TranslatableString(getKey(@"epilepsy_warning_description"), @"Recommended if the storyboard or video contain scenes with rapidly flashing colours."); + public static LocalisableString EpilepsyWarningDescription => new TranslatableString(getKey(@"epilepsy_warning_description"), @"Recommended if the storyboard or video contain scenes with rapidly flashing colours."); /// /// "Letterbox during breaks" @@ -91,8 +87,7 @@ namespace osu.Game.Localisation /// /// "Adds horizontal letterboxing to give a cinematic look during breaks." /// - public static LocalisableString LetterboxDuringBreaksDescription => - new TranslatableString(getKey(@"letterbox_during_breaks_description"), @"Adds horizontal letterboxing to give a cinematic look during breaks."); + public static LocalisableString LetterboxDuringBreaksDescription => new TranslatableString(getKey(@"letterbox_during_breaks_description"), @"Adds horizontal letterboxing to give a cinematic look during breaks."); /// /// "Samples match playback rate" @@ -102,8 +97,7 @@ namespace osu.Game.Localisation /// /// "When enabled, all samples will speed up or slow down when rate-changing mods are enabled." /// - public static LocalisableString SamplesMatchPlaybackRateDescription => new TranslatableString(getKey(@"samples_match_playback_rate_description"), - @"When enabled, all samples will speed up or slow down when rate-changing mods are enabled."); + public static LocalisableString SamplesMatchPlaybackRateDescription => new TranslatableString(getKey(@"samples_match_playback_rate_description"), @"When enabled, all samples will speed up or slow down when rate-changing mods are enabled."); /// /// "The size of all hit objects" @@ -123,8 +117,27 @@ namespace osu.Game.Localisation /// /// "The harshness of hit windows and difficulty of special objects (ie. spinners)" /// - public static LocalisableString OverallDifficultyDescription => - new TranslatableString(getKey(@"overall_difficulty_description"), @"The harshness of hit windows and difficulty of special objects (ie. spinners)"); + public static LocalisableString OverallDifficultyDescription => new TranslatableString(getKey(@"overall_difficulty_description"), @"The harshness of hit windows and difficulty of special objects (ie. spinners)"); + + /// + /// "Tick Rate" + /// + public static LocalisableString TickRate => new TranslatableString(getKey(@"tick_rate"), @"Tick Rate"); + + /// + /// "Determines how many "ticks" are generated within long hit objects. A tick rate of 1 will generate ticks on each beat, 2 would be twice per beat, etc." + /// + public static LocalisableString TickRateDescription => new TranslatableString(getKey(@"tick_rate_description"), @"Determines how many ""ticks"" are generated within long hit objects. A tick rate of 1 will generate ticks on each beat, 2 would be twice per beat, etc."); + + /// + /// "Base Velocity" + /// + public static LocalisableString BaseVelocity => new TranslatableString(getKey(@"base_velocity"), @"Base Velocity"); + + /// + /// "The base velocity of the beatmap, affecting things like slider velocity and scroll speed in some rulesets." + /// + public static LocalisableString BaseVelocityDescription => new TranslatableString(getKey(@"base_velocity_description"), @"The base velocity of the beatmap, affecting things like slider velocity and scroll speed in some rulesets."); /// /// "Metadata" diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index f4e23ae7cb..20258b9c35 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -99,6 +99,16 @@ namespace osu.Game.Localisation /// public static LocalisableString TimelineTicks => new TranslatableString(getKey(@"timeline_ticks"), @"Ticks"); + /// + /// "{0:0}°" + /// + public static LocalisableString RotationUnsnapped(float newRotation) => new TranslatableString(getKey(@"rotation_unsnapped"), @"{0:0}°", newRotation); + + /// + /// "{0:0}° (snapped)" + /// + public static LocalisableString RotationSnapped(float newRotation) => new TranslatableString(getKey(@"rotation_snapped"), @"{0:0}° (snapped)", newRotation); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs b/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs index 3a7fe4bb12..a77ee066e4 100644 --- a/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs +++ b/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs @@ -15,9 +15,9 @@ namespace osu.Game.Localisation public static LocalisableString Header => new TranslatableString(getKey(@"header"), @"Obtaining Beatmaps"); /// - /// ""Beatmaps" are what we call playable levels. osu! doesn't come with any beatmaps pre-loaded. This step will help you get started on your beatmap collection." + /// ""Beatmaps" are what we call sets of playable levels. osu! doesn't come with any beatmaps pre-loaded. This step will help you get started on your beatmap collection." /// - public static LocalisableString Description => new TranslatableString(getKey(@"description"), @"""Beatmaps"" are what we call playable levels. osu! doesn't come with any beatmaps pre-loaded. This step will help you get started on your beatmap collection."); + public static LocalisableString Description => new TranslatableString(getKey(@"description"), @"""Beatmaps"" are what we call sets of playable levels. osu! doesn't come with any beatmaps pre-loaded. This step will help you get started on your beatmap collection."); /// /// "If you are a new player, we recommend playing through the tutorial to get accustomed to the gameplay." diff --git a/osu.Game/Localisation/GameplayMenuOverlayStrings.cs b/osu.Game/Localisation/GameplayMenuOverlayStrings.cs new file mode 100644 index 0000000000..f1a65ab430 --- /dev/null +++ b/osu.Game/Localisation/GameplayMenuOverlayStrings.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class GameplayMenuOverlayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.GameplayMenuOverlay"; + + /// + /// "Continue" + /// + public static LocalisableString Continue => new TranslatableString(getKey(@"continue"), @"Continue"); + + /// + /// "Retry" + /// + public static LocalisableString Retry => new TranslatableString(getKey(@"retry"), @"Retry"); + + /// + /// "Quit" + /// + public static LocalisableString Quit => new TranslatableString(getKey(@"quit"), @"Quit"); + + /// + /// "failed" + /// + public static LocalisableString FailedHeader => new TranslatableString(getKey(@"failed_header"), @"failed"); + + /// + /// "paused" + /// + public static LocalisableString PausedHeader => new TranslatableString(getKey(@"paused_header"), @"paused"); + + /// + /// "Retry count: " + /// + public static LocalisableString RetryCount => new TranslatableString(getKey(@"retry_count"), @"Retry count: "); + + /// + /// "Song progress: " + /// + public static LocalisableString SongProgress => new TranslatableString(getKey(@"song_progress"), @"Song progress: "); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index 6e05929d81..422704514f 100644 --- a/osu.Game/Localisation/GraphicsSettingsStrings.cs +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString RendererHeader => new TranslatableString(getKey(@"renderer_header"), @"Renderer"); + /// + /// "Renderer" + /// + public static LocalisableString Renderer => new TranslatableString(getKey(@"renderer"), @"Renderer"); + /// /// "Frame limiter" /// @@ -144,6 +149,12 @@ namespace osu.Game.Localisation /// public static LocalisableString Png => new TranslatableString(getKey(@"png_lossless"), @"PNG (lossless)"); + /// + /// "In order to change the renderer, the game will close. Please open it again." + /// + public static LocalisableString ChangeRendererConfirmation => + new TranslatableString(getKey(@"change_renderer_configuration"), @"In order to change the renderer, the game will close. Please open it again."); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs index 6a4e5110e6..711e95486f 100644 --- a/osu.Game/Localisation/Language.cs +++ b/osu.Game/Localisation/Language.cs @@ -22,6 +22,9 @@ namespace osu.Game.Localisation [Description(@"Български")] bg, + [Description(@"Català")] + ca, + [Description(@"Česky")] cs, @@ -37,12 +40,27 @@ namespace osu.Game.Localisation [Description(@"español")] es, + // TODO: Requires Arabic glyphs to be added to resources (and possibly also RTL support). + // [Description(@"فارسی")] + // fa_ir, + [Description(@"Suomi")] fi, + // TODO: Doesn't work as appropriate satellite assemblies aren't copied from resources (see: https://github.com/ppy/osu/discussions/18851#discussioncomment-3042170) + // [Description(@"Filipino")] + // fil, + [Description(@"français")] fr, + // TODO: Requires Hebrew glyphs to be added to resources (and possibly also RTL support). + // [Description(@"עברית")] + // he, + + [Description(@"Hrvatski")] + hr_hr, + [Description(@"Magyar")] hu, @@ -58,6 +76,15 @@ namespace osu.Game.Localisation [Description(@"한국어")] ko, + [Description(@"Lietuvių")] + lt, + + [Description(@"Latviešu")] + lv_lv, + + [Description(@"Melayu")] + ms_my, + [Description(@"Nederlands")] nl, @@ -79,12 +106,28 @@ namespace osu.Game.Localisation [Description(@"Русский")] ru, + // TODO: Requires Sinhala glyphs to be added to resources. + // Additionally, no translations available yet. + // [Description(@"සිංහල")] + // si_lk, + [Description(@"Slovenčina")] sk, + [Description(@"Slovenščina")] + sl, + + [Description(@"Српски")] + sr, + [Description(@"Svenska")] sv, + // Tajik has no associated localisations yet, and is not supported on Windows versions <10. + // TODO: update language mapping in osu-resources to redirect tg-TJ to tg-Cyrl-TJ (which is supported on earlier Windows versions) + // [Description(@"Тоҷикӣ")] + // tg_tj, + [Description(@"ไทย")] th, diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index d6a01c4794..f11c52ee20 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -34,6 +34,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AddPreset => new TranslatableString(getKey(@"add_preset"), @"Add preset"); + /// + /// "Use current mods" + /// + public static LocalisableString UseCurrentMods => new TranslatableString(getKey(@"use_current_mods"), @"Use current mods"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 6a9793b20c..5e2600bc50 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -50,16 +50,18 @@ namespace osu.Game.Localisation public static LocalisableString NoAutoplayMod => new TranslatableString(getKey(@"no_autoplay_mod"), @"The current ruleset doesn't have an autoplay mod available!"); /// - /// "osu! doesn't seem to be able to play audio correctly.\n\nPlease try changing your audio device to a working setting." + /// "osu! doesn't seem to be able to play audio correctly. + /// + /// Please try changing your audio device to a working setting." /// - public static LocalisableString AudioPlaybackIssue => new TranslatableString(getKey(@"audio_playback_issue"), - @"osu! doesn't seem to be able to play audio correctly.\n\nPlease try changing your audio device to a working setting."); + public static LocalisableString AudioPlaybackIssue => new TranslatableString(getKey(@"audio_playback_issue"), @"osu! doesn't seem to be able to play audio correctly. + +Please try changing your audio device to a working setting."); /// /// "The score overlay is currently disabled. You can toggle this by pressing {0}." /// - public static LocalisableString ScoreOverlayDisabled(LocalisableString arg0) => new TranslatableString(getKey(@"score_overlay_disabled"), - @"The score overlay is currently disabled. You can toggle this by pressing {0}.", arg0); + public static LocalisableString ScoreOverlayDisabled(LocalisableString arg0) => new TranslatableString(getKey(@"score_overlay_disabled"), @"The score overlay is currently disabled. You can toggle this by pressing {0}.", arg0); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs index d2ff783413..3fa86c188c 100644 --- a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs +++ b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs @@ -65,6 +65,11 @@ namespace osu.Game.Localisation if (manager == null) return null; + // When using the English culture, prefer the fallbacks rather than osu-resources baked strings. + // They are guaranteed to be up-to-date, and is also what a developer expects to see when making changes to `xxxStrings.cs` files. + if (EffectiveCulture.Name == @"en") + return null; + try { return manager.GetString(key, EffectiveCulture); diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs index 1b0df6ecf6..91bbece004 100644 --- a/osu.Game/Localisation/RulesetSettingsStrings.cs +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -29,6 +29,11 @@ namespace osu.Game.Localisation /// public static LocalisableString CursorTrail => new TranslatableString(getKey(@"cursor_trail"), @"Cursor trail"); + /// + /// "Cursor ripples" + /// + public static LocalisableString CursorRipples => new TranslatableString(getKey(@"cursor_ripples"), @"Cursor ripples"); + /// /// "Playfield border style" /// @@ -77,7 +82,7 @@ namespace osu.Game.Localisation /// /// "{0}ms (speed {1})" /// - public static LocalisableString ScrollSpeedTooltip(double arg0, int arg1) => new TranslatableString(getKey(@"ruleset"), @"{0}ms (speed {1})", arg0, arg1); + public static LocalisableString ScrollSpeedTooltip(double scrollTime, int scrollSpeed) => new TranslatableString(getKey(@"ruleset"), @"{0:0}ms (speed {1})", scrollTime, scrollSpeed); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/SongSelectStrings.cs b/osu.Game/Localisation/SongSelectStrings.cs index 12f70cd967..e1ac328420 100644 --- a/osu.Game/Localisation/SongSelectStrings.cs +++ b/osu.Game/Localisation/SongSelectStrings.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Localisation; diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs index f96b717937..2faa3f0ca6 100644 --- a/osu.Game/Models/RealmFile.cs +++ b/osu.Game/Models/RealmFile.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 osu.Framework.Testing; using osu.Game.IO; using Realms; namespace osu.Game.Models { - [ExcludeFromDynamicCompile] [MapTo("File")] public class RealmFile : RealmObject, IFileInfo { diff --git a/osu.Game/Models/RealmNamedFileUsage.cs b/osu.Game/Models/RealmNamedFileUsage.cs index c4310c4edb..c8e2c6f6d0 100644 --- a/osu.Game/Models/RealmNamedFileUsage.cs +++ b/osu.Game/Models/RealmNamedFileUsage.cs @@ -3,14 +3,12 @@ using System; using JetBrains.Annotations; -using osu.Framework.Testing; using osu.Game.Database; using osu.Game.IO; using Realms; namespace osu.Game.Models { - [ExcludeFromDynamicCompile] public class RealmNamedFileUsage : EmbeddedObject, INamedFile, INamedFileUsage { public RealmFile File { get; set; } = null!; diff --git a/osu.Game/Online/API/Requests/ChatReportRequest.cs b/osu.Game/Online/API/Requests/ChatReportRequest.cs new file mode 100644 index 0000000000..85e5559e01 --- /dev/null +++ b/osu.Game/Online/API/Requests/ChatReportRequest.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.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Overlays.Chat; + +namespace osu.Game.Online.API.Requests +{ + public class ChatReportRequest : APIRequest + { + public readonly long? MessageId; + public readonly ChatReportReason Reason; + public readonly string Comment; + + public ChatReportRequest(long? id, ChatReportReason reason, string comment) + { + MessageId = id; + Reason = reason; + Comment = comment; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + + req.AddParameter(@"reportable_type", @"message"); + req.AddParameter(@"reportable_id", $"{MessageId}"); + req.AddParameter(@"reason", Reason.ToString()); + req.AddParameter(@"comments", Comment); + + return req; + } + + protected override string Target => @"reports"; + } +} diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 9b2ad666b2..52bdd36169 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -12,6 +12,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Online.API; @@ -35,6 +36,9 @@ namespace osu.Game.Online.Chat [Resolved] private ChannelManager channelManager { get; set; } + [Resolved] + private GameHost host { get; set; } + private Bindable notifyOnUsername; private Bindable notifyOnPrivateMessage; @@ -89,8 +93,8 @@ namespace osu.Game.Online.Chat if (channel == null) return; - // Only send notifications, if ChatOverlay and the target channel aren't visible. - if (chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel) + // Only send notifications if ChatOverlay or the target channel aren't visible, or if the window is unfocused + if (chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel && host.IsActive.Value) return; foreach (var message in messages.OrderByDescending(m => m.Id)) @@ -99,6 +103,7 @@ namespace osu.Game.Online.Chat if (message.Id <= channel.LastReadId) return; + // ignore notifications triggered by local user's own chat messages if (message.Sender.Id == localUser.Value.Id) continue; diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index e20b28ee0c..e4ea277756 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -17,8 +17,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Framework.Platform; -using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -66,18 +64,18 @@ namespace osu.Game.Online.Leaderboards private List statisticsLabels; - [Resolved(CanBeNull = true)] + [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } - [Resolved(CanBeNull = true)] + [Resolved(canBeNull: true)] private SongSelect songSelect { get; set; } - [Resolved] - private Storage storage { get; set; } - public ITooltip GetCustomTooltip() => new LeaderboardScoreTooltip(); public virtual ScoreInfo TooltipContent => Score; + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true) { Score = score; @@ -90,7 +88,7 @@ namespace osu.Game.Online.Leaderboards } [BackgroundDependencyLoader] - private void load(IAPIProvider api, OsuColour colour, ScoreManager scoreManager) + private void load(IAPIProvider api, OsuColour colour) { var user = Score.User; @@ -427,7 +425,7 @@ namespace osu.Game.Online.Leaderboards if (Score.Files.Count > 0) { - items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => new LegacyScoreExporter(storage).Export(Score))); + items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(Score))); items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 2be7327234..5716b7ad3b 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -783,7 +783,7 @@ namespace osu.Game.Online.Multiplayer RoomUpdated?.Invoke(); } - private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID }) + private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating }) { ID = item.ID, OwnerID = item.OwnerID, diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index c45f703b05..8be703e620 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -53,22 +53,12 @@ namespace osu.Game.Online.Rooms [Key(9)] public DateTimeOffset? PlayedAt { get; set; } + [Key(10)] + public double StarRating { get; set; } + + [SerializationConstructor] public MultiplayerPlaylistItem() { } - - 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; - } } } diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 2213311c67..a900d8f3d7 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -91,7 +91,7 @@ namespace osu.Game.Online.Rooms } public PlaylistItem(MultiplayerPlaylistItem item) - : this(new APIBeatmap { OnlineID = item.BeatmapID }) + : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating }) { ID = item.ID; OwnerID = item.OwnerID; diff --git a/osu.Game/Online/Spectator/FrameDataBundle.cs b/osu.Game/Online/Spectator/FrameDataBundle.cs index 97ae468875..d58ddd5310 100644 --- a/osu.Game/Online/Spectator/FrameDataBundle.cs +++ b/osu.Game/Online/Spectator/FrameDataBundle.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using MessagePack; using Newtonsoft.Json; using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Online.Spectator @@ -20,10 +21,10 @@ namespace osu.Game.Online.Spectator [Key(1)] public IList Frames { get; set; } - public FrameDataBundle(ScoreInfo score, IList frames) + public FrameDataBundle(ScoreInfo score, ScoreProcessor scoreProcessor, IList frames) { Frames = frames; - Header = new FrameHeader(score); + Header = new FrameHeader(score, scoreProcessor.GetScoreProcessorStatistics()); } [JsonConstructor] diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs index b6dcd8aaa5..45f920e65b 100644 --- a/osu.Game/Online/Spectator/FrameHeader.cs +++ b/osu.Game/Online/Spectator/FrameHeader.cs @@ -15,57 +15,74 @@ namespace osu.Game.Online.Spectator public class FrameHeader { /// - /// The current accuracy of the score. + /// The total score. /// [Key(0)] + public long TotalScore { get; set; } + + /// + /// The current accuracy of the score. + /// + [Key(1)] public double Accuracy { get; set; } /// /// The current combo of the score. /// - [Key(1)] + [Key(2)] public int Combo { get; set; } /// /// The maximum combo achieved up to the current point in time. /// - [Key(2)] + [Key(3)] public int MaxCombo { get; set; } /// /// Cumulative hit statistics. /// - [Key(3)] + [Key(4)] public Dictionary Statistics { get; set; } + /// + /// Additional statistics that guides the score processor to calculate the correct score for this frame. + /// + [Key(5)] + public ScoreProcessorStatistics ScoreProcessorStatistics { get; set; } + /// /// The time at which this frame was received by the server. /// - [Key(4)] + [Key(6)] public DateTimeOffset ReceivedTime { get; set; } /// /// Construct header summary information from a point-in-time reference to a score which is actively being played. /// /// The score for reference. - public FrameHeader(ScoreInfo score) + /// The score processor statistics for the current point in time. + public FrameHeader(ScoreInfo score, ScoreProcessorStatistics statistics) { + TotalScore = score.TotalScore; + Accuracy = score.Accuracy; Combo = score.Combo; MaxCombo = score.MaxCombo; - Accuracy = score.Accuracy; - // copy for safety Statistics = new Dictionary(score.Statistics); + + ScoreProcessorStatistics = statistics; } [JsonConstructor] [SerializationConstructor] - public FrameHeader(double accuracy, int combo, int maxCombo, Dictionary statistics, DateTimeOffset receivedTime) + public FrameHeader(long totalScore, double accuracy, int combo, int maxCombo, Dictionary statistics, ScoreProcessorStatistics scoreProcessorStatistics, DateTimeOffset receivedTime) { + TotalScore = totalScore; + Accuracy = accuracy; Combo = combo; MaxCombo = maxCombo; - Accuracy = accuracy; Statistics = statistics; + ScoreProcessorStatistics = scoreProcessorStatistics; ReceivedTime = receivedTime; } } diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 55ec75f4ce..89da8b9d32 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -16,6 +16,7 @@ using osu.Game.Online.API; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -82,6 +83,7 @@ namespace osu.Game.Online.Spectator private IBeatmap? currentBeatmap; private Score? currentScore; private long? currentScoreToken; + private ScoreProcessor? currentScoreProcessor; private readonly Queue pendingFrameBundles = new Queue(); @@ -192,6 +194,7 @@ namespace osu.Game.Online.Spectator currentBeatmap = state.Beatmap; currentScore = score; currentScoreToken = scoreToken; + currentScoreProcessor = state.ScoreProcessor; BeginPlayingInternal(currentScoreToken, currentState); }); @@ -302,9 +305,10 @@ namespace osu.Game.Online.Spectator return; Debug.Assert(currentScore != null); + Debug.Assert(currentScoreProcessor != null); var frames = pendingFrames.ToArray(); - var bundle = new FrameDataBundle(currentScore.ScoreInfo, frames); + var bundle = new FrameDataBundle(currentScore.ScoreInfo, currentScoreProcessor, frames); pendingFrames.Clear(); lastPurgeTime = Time.Current; diff --git a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs index 1c505ea107..3242e21994 100644 --- a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs +++ b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; namespace osu.Game.Online.Spectator { @@ -46,7 +47,9 @@ namespace osu.Game.Online.Spectator /// /// The applied s. /// - public IReadOnlyList Mods => scoreProcessor?.Mods.Value ?? Array.Empty(); + public IReadOnlyList Mods => scoreInfo?.Mods ?? Array.Empty(); + + public Func GetDisplayScore => mode => scoreInfo?.GetDisplayScore(mode) ?? 0; private IClock? referenceClock; @@ -70,7 +73,6 @@ namespace osu.Game.Online.Spectator private readonly int userId; private SpectatorState? spectatorState; - private ScoreProcessor? scoreProcessor; private ScoreInfo? scoreInfo; public SpectatorScoreProcessor(int userId) @@ -94,19 +96,15 @@ namespace osu.Game.Online.Spectator { if (!spectatorStates.TryGetValue(userId, out var userState) || userState.BeatmapID == null || userState.RulesetID == null) { - scoreProcessor?.RemoveAndDisposeImmediately(); - scoreProcessor = null; scoreInfo = null; spectatorState = null; replayFrames.Clear(); return; } - if (scoreProcessor != null) + if (scoreInfo != null) return; - Debug.Assert(scoreInfo == null); - RulesetInfo? rulesetInfo = rulesetStore.GetRuleset(userState.RulesetID.Value); if (rulesetInfo == null) return; @@ -114,9 +112,11 @@ namespace osu.Game.Online.Spectator Ruleset ruleset = rulesetInfo.CreateInstance(); spectatorState = userState; - scoreInfo = new ScoreInfo { Ruleset = rulesetInfo }; - scoreProcessor = ruleset.CreateScoreProcessor(); - scoreProcessor.Mods.Value = userState.Mods.Select(m => m.ToMod(ruleset)).ToArray(); + scoreInfo = new ScoreInfo + { + Ruleset = rulesetInfo, + Mods = userState.Mods.Select(m => m.ToMod(ruleset)).ToArray() + }; } private void onNewFrames(int incomingUserId, FrameDataBundle bundle) @@ -126,7 +126,7 @@ namespace osu.Game.Online.Spectator Schedule(() => { - if (scoreProcessor == null) + if (scoreInfo == null) return; replayFrames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header)); @@ -140,7 +140,6 @@ namespace osu.Game.Online.Spectator return; Debug.Assert(spectatorState != null); - Debug.Assert(scoreProcessor != null); int frameIndex = replayFrames.BinarySearch(new TimedFrame(ReferenceClock.CurrentTime)); if (frameIndex < 0) @@ -150,14 +149,15 @@ namespace osu.Game.Online.Spectator TimedFrame frame = replayFrames[frameIndex]; Debug.Assert(frame.Header != null); + scoreInfo.Accuracy = frame.Header.Accuracy; scoreInfo.MaxCombo = frame.Header.MaxCombo; scoreInfo.Statistics = frame.Header.Statistics; scoreInfo.MaximumStatistics = spectatorState.MaximumStatistics; + scoreInfo.TotalScore = frame.Header.TotalScore; Accuracy.Value = frame.Header.Accuracy; Combo.Value = frame.Header.Combo; - - TotalScore.Value = scoreProcessor.ComputeScore(Mode.Value, scoreInfo); + TotalScore.Value = frame.Header.TotalScore; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 7c9b03bd5b..3768dad370 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -269,6 +269,13 @@ namespace osu.Game if (hideToolbar) Toolbar.Hide(); } + protected override UserInputManager CreateUserInputManager() + { + var userInputManager = base.CreateUserInputManager(); + (userInputManager as OsuUserInputManager)?.LocalUserPlaying.BindTo(LocalUserPlaying); + return userInputManager; + } + private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => @@ -1129,12 +1136,22 @@ namespace osu.Game if (entry.Level == LogLevel.Error) { - Schedule(() => Notifications.Post(new SimpleNotification + Schedule(() => { - Text = $"Encountered tablet error: \"{message}\"", - Icon = FontAwesome.Solid.PenSquare, - IconColour = Colours.RedDark, - })); + Notifications.Post(new SimpleNotification + { + Text = $"Disabling tablet support due to error: \"{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) { diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index cf58d07b9e..c55b6c249f 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -58,7 +58,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Skinning; using osu.Game.Utils; -using File = System.IO.File; using RuntimeInfo = osu.Framework.RuntimeInfo; namespace osu.Game @@ -71,7 +70,7 @@ namespace osu.Game [Cached(typeof(OsuGameBase))] public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { - public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv" }; + public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv", ".mpg", ".wmv", ".m4v" }; public const string OSU_PROTOCOL = "osu://"; @@ -310,7 +309,7 @@ namespace osu.Game base.Content.Add(new BeatmapOnlineChangeIngest(beatmapUpdater, realm, metadataClient)); - BeatmapManager.ProcessBeatmap = args => beatmapUpdater.Process(args.beatmapSet, !args.isBatch); + BeatmapManager.ProcessBeatmap = (beatmapSet, scope) => beatmapUpdater.Process(beatmapSet, scope); dependencies.Cache(userCache = new UserLookupCache()); base.Content.Add(userCache); @@ -626,15 +625,22 @@ namespace osu.Game return; } - var previouslySelectedMods = SelectedMods.Value.ToArray(); - - if (!SelectedMods.Disabled) - SelectedMods.Value = Array.Empty(); - AvailableMods.Value = dict; - if (!SelectedMods.Disabled) - SelectedMods.Value = previouslySelectedMods.Select(m => instance.CreateModFromAcronym(m.Acronym)).Where(m => m != null).ToArray(); + if (SelectedMods.Disabled) + return; + + var convertedMods = SelectedMods.Value.Select(mod => + { + var newMod = instance.CreateModFromAcronym(mod.Acronym); + newMod?.CopyCommonSettingsFrom(mod); + return newMod; + }).Where(newMod => newMod != null).ToList(); + + if (!ModUtils.CheckValidForGameplay(convertedMods, out var invalid)) + invalid.ForEach(newMod => convertedMods.Remove(newMod)); + + SelectedMods.Value = convertedMods; void revertRulesetChange() => Ruleset.Value = r.OldValue?.Available == true ? r.OldValue : RulesetStore.AvailableRulesets.First(); } diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index 2e20f83e9e..219cbe7eef 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -209,7 +209,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}"); + game.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", true); } } else diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs index 76b6dec65b..3336c383ff 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs @@ -3,6 +3,7 @@ #nullable disable +using osu.Framework.Graphics; using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; @@ -10,8 +11,12 @@ namespace osu.Game.Overlays.BeatmapListing { public partial class BeatmapListingHeader : OverlayHeader { + public BeatmapListingFilterControl FilterControl { get; private set; } + protected override OverlayTitle CreateTitle() => new BeatmapListingTitle(); + protected override Drawable CreateContent() => FilterControl = new BeatmapListingFilterControl(); + private partial class BeatmapListingTitle : OverlayTitle { public BeatmapListingTitle() diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index 23de1cf76d..3fa0fc7a77 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -107,7 +107,7 @@ namespace osu.Game.Overlays.BeatmapListing Padding = new MarginPadding { Vertical = 20, - Horizontal = 40, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, }, Child = new FillFlowContainer { diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs index 025738710f..2f290d05e9 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs @@ -25,6 +25,8 @@ namespace osu.Game.Overlays.BeatmapListing if (currentParameters == null) Reset(SearchCategory.Leaderboard, false); + + Current.BindValueChanged(_ => SortDirection.Value = Overlays.SortDirection.Descending); } public void Reset(SearchCategory category, bool hasQuery) @@ -102,7 +104,7 @@ namespace osu.Game.Overlays.BeatmapListing }; } - private partial class BeatmapTabButton : TabButton + public partial class BeatmapTabButton : TabButton { public readonly Bindable SortDirection = new Bindable(); @@ -136,7 +138,7 @@ namespace osu.Game.Overlays.BeatmapListing SortDirection.BindValueChanged(direction => { - icon.Icon = direction.NewValue == Overlays.SortDirection.Ascending ? FontAwesome.Solid.CaretUp : FontAwesome.Solid.CaretDown; + icon.Icon = direction.NewValue == Overlays.SortDirection.Ascending && Active.Value ? FontAwesome.Solid.CaretUp : FontAwesome.Solid.CaretDown; }, true); } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 73961487ed..f8784504b8 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -43,7 +43,8 @@ namespace osu.Game.Overlays private Container panelTarget; private FillFlowContainer foundContent; - private BeatmapListingFilterControl filterControl; + + private BeatmapListingFilterControl filterControl => Header.FilterControl; public BeatmapListingOverlay() : base(OverlayColourScheme.Blue) @@ -60,12 +61,6 @@ namespace osu.Game.Overlays Direction = FillDirection.Vertical, Children = new Drawable[] { - filterControl = new BeatmapListingFilterControl - { - TypingStarted = onTypingStarted, - SearchStarted = onSearchStarted, - SearchFinished = onSearchFinished, - }, new Container { AutoSizeAxes = Axes.Y, @@ -88,6 +83,10 @@ namespace osu.Game.Overlays }, } }; + + filterControl.TypingStarted = onTypingStarted; + filterControl.SearchStarted = onSearchStarted; + filterControl.SearchFinished = onSearchFinished; } protected override void LoadComplete() diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index 585e0dd1a2..104f861df7 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -31,6 +31,7 @@ namespace osu.Game.Overlays.BeatmapSet private const float tile_spacing = 2; private readonly OsuSpriteText version, starRating, starRatingText; + private readonly LinkFlowContainer guestMapperContainer; private readonly FillFlowContainer starRatingContainer; private readonly Statistic plays, favourites; @@ -88,6 +89,14 @@ namespace osu.Game.Overlays.BeatmapSet 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, @@ -198,8 +207,21 @@ namespace osu.Game.Overlays.BeatmapSet updateDifficultyButtons(); } - private void showBeatmap(IBeatmapInfo? beatmapInfo) + private void showBeatmap(APIBeatmap? beatmapInfo) { + guestMapperContainer.Clear(); + + if (beatmapInfo?.AuthorID != BeatmapSet?.AuthorID) + { + APIUser? user = BeatmapSet?.RelatedUsers?.SingleOrDefault(u => u.OnlineID == beatmapInfo?.AuthorID); + + if (user != null) + { + guestMapperContainer.AddText("mapped by "); + guestMapperContainer.AddUserLink(user); + } + } + version.Text = beatmapInfo?.DifficultyName ?? string.Empty; } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index 26e6b1f158..7ff8352054 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -97,8 +97,8 @@ namespace osu.Game.Overlays.BeatmapSet Padding = new MarginPadding { Vertical = BeatmapSetOverlay.Y_PADDING, - Left = BeatmapSetOverlay.X_PADDING, - Right = BeatmapSetOverlay.X_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, + Left = WaveOverlayContainer.HORIZONTAL_PADDING, + Right = WaveOverlayContainer.HORIZONTAL_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, }, Children = new Drawable[] { @@ -170,7 +170,7 @@ namespace osu.Game.Overlays.BeatmapSet Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = BeatmapSetOverlay.Y_PADDING, Right = BeatmapSetOverlay.X_PADDING }, + Margin = new MarginPadding { Top = BeatmapSetOverlay.Y_PADDING, Right = WaveOverlayContainer.HORIZONTAL_PADDING }, Direction = FillDirection.Vertical, Spacing = new Vector2(10), Children = new Drawable[] diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 58739eb471..8758b9c5cf 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -55,7 +55,7 @@ namespace osu.Game.Overlays.BeatmapSet new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 15, Horizontal = BeatmapSetOverlay.X_PADDING }, + Padding = new MarginPadding { Top = 15, Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING }, Children = new Drawable[] { new Container diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 9eb04d9cc5..6d89313979 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -116,7 +116,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = 50 }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING }, Margin = new MarginPadding { Vertical = 20 }, Children = new Drawable[] { diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 237ce22767..873336bb6e 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -25,7 +25,6 @@ namespace osu.Game.Overlays { public partial class BeatmapSetOverlay : OnlineOverlay { - public const float X_PADDING = 40; public const float Y_PADDING = 25; public const float RIGHT_WIDTH = 275; diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs index 96d5203d14..08978ac2ab 100644 --- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs @@ -18,8 +18,6 @@ namespace osu.Game.Overlays.Changelog { public partial class ChangelogBuild : FillFlowContainer { - public const float HORIZONTAL_PADDING = 70; - public Action SelectBuild; protected readonly APIChangelogBuild Build; @@ -33,7 +31,7 @@ namespace osu.Game.Overlays.Changelog RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; - Padding = new MarginPadding { Horizontal = HORIZONTAL_PADDING }; + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING }; Children = new Drawable[] { diff --git a/osu.Game/Overlays/Changelog/ChangelogHeader.cs b/osu.Game/Overlays/Changelog/ChangelogHeader.cs index 54ada24987..e9be67e977 100644 --- a/osu.Game/Overlays/Changelog/ChangelogHeader.cs +++ b/osu.Game/Overlays/Changelog/ChangelogHeader.cs @@ -93,7 +93,7 @@ namespace osu.Game.Overlays.Changelog AutoSizeAxes = Axes.Y, Padding = new MarginPadding { - Horizontal = 65, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - ChangelogUpdateStreamItem.PADDING, Vertical = 20 }, Child = Streams = new ChangelogUpdateStreamControl { Current = currentStream }, diff --git a/osu.Game/Overlays/Changelog/ChangelogListing.cs b/osu.Game/Overlays/Changelog/ChangelogListing.cs index d7c9ff67fe..4b784c7a28 100644 --- a/osu.Game/Overlays/Changelog/ChangelogListing.cs +++ b/osu.Game/Overlays/Changelog/ChangelogListing.cs @@ -64,7 +64,7 @@ namespace osu.Game.Overlays.Changelog { RelativeSizeAxes = Axes.X, Height = 1, - Padding = new MarginPadding { Horizontal = ChangelogBuild.HORIZONTAL_PADDING }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING }, Margin = new MarginPadding { Top = 30 }, Child = new Box { diff --git a/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs b/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs index 04526eb7ba..4aded1dd59 100644 --- a/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs +++ b/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.Changelog Padding = new MarginPadding { Vertical = 20, - Horizontal = 50, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, }; } @@ -79,7 +79,7 @@ namespace osu.Game.Overlays.Changelog Direction = FillDirection.Vertical, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Right = 50 + image_container_width }, + Padding = new MarginPadding { Right = WaveOverlayContainer.HORIZONTAL_PADDING + image_container_width }, Children = new Drawable[] { new OsuSpriteText diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 70c3bf181c..2f4c175ac4 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -1,25 +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 System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.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.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; -using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Chat { - public partial class ChatLine : CompositeDrawable + public partial class ChatLine : CompositeDrawable, IHasPopover { private Message message = null!; @@ -55,7 +58,7 @@ namespace osu.Game.Overlays.Chat private readonly OsuSpriteText drawableTimestamp; - private readonly DrawableUsername drawableUsername; + private readonly DrawableChatUsername drawableUsername; private readonly LinkFlowContainer drawableContentFlow; @@ -92,7 +95,7 @@ namespace osu.Game.Overlays.Chat Font = OsuFont.GetFont(size: FontSize * 0.75f, weight: FontWeight.SemiBold, fixedWidth: true), AlwaysPresent = true, }, - drawableUsername = new DrawableUsername(message.Sender) + drawableUsername = new DrawableChatUsername(message.Sender) { Width = UsernameWidth, FontSize = FontSize, @@ -100,6 +103,7 @@ namespace osu.Game.Overlays.Chat Origin = Anchor.TopRight, Anchor = Anchor.TopRight, Margin = new MarginPadding { Horizontal = Spacing }, + ReportRequested = this.ShowPopover, }, drawableContentFlow = new LinkFlowContainer(styleMessageContent) { @@ -128,6 +132,8 @@ namespace osu.Game.Overlays.Chat FinishTransforms(true); } + public Popover GetPopover() => new ReportChatPopover(message); + /// /// Performs a highlight animation on this . /// diff --git a/osu.Game/Overlays/Chat/ChatReportReason.cs b/osu.Game/Overlays/Chat/ChatReportReason.cs new file mode 100644 index 0000000000..5fda11b61e --- /dev/null +++ b/osu.Game/Overlays/Chat/ChatReportReason.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Chat +{ + /// + /// References: + /// https://github.com/ppy/osu-web/blob/0a41b13acf5f47bb0d2b08bab42a9646b7ab5821/app/Models/UserReport.php#L50 + /// https://github.com/ppy/osu-web/blob/0a41b13acf5f47bb0d2b08bab42a9646b7ab5821/app/Models/UserReport.php#L39 + /// + public enum ChatReportReason + { + [Description("Insulting People")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsInsults))] + Insults, + + [Description("Spam")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsSpam))] + Spam, + + [Description("Unwanted Content")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsUnwantedContent))] + UnwantedContent, + + [Description("Nonsense")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsNonsense))] + Nonsense, + + [Description("Other")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsOther))] + Other + } +} diff --git a/osu.Game/Overlays/Chat/DrawableUsername.cs b/osu.Game/Overlays/Chat/DrawableChatUsername.cs similarity index 95% rename from osu.Game/Overlays/Chat/DrawableUsername.cs rename to osu.Game/Overlays/Chat/DrawableChatUsername.cs index 031a0b6ae2..4b4afc204c 100644 --- a/osu.Game/Overlays/Chat/DrawableUsername.cs +++ b/osu.Game/Overlays/Chat/DrawableChatUsername.cs @@ -29,8 +29,10 @@ using ChatStrings = osu.Game.Localisation.ChatStrings; namespace osu.Game.Overlays.Chat { - public partial class DrawableUsername : OsuClickableContainer, IHasContextMenu + public partial class DrawableChatUsername : OsuClickableContainer, IHasContextMenu { + public Action? ReportRequested; + public Color4 AccentColour { get; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => @@ -75,7 +77,7 @@ namespace osu.Game.Overlays.Chat private readonly Drawable colouredDrawable; - public DrawableUsername(APIUser user) + public DrawableChatUsername(APIUser user) { this.user = user; @@ -169,6 +171,9 @@ namespace osu.Game.Overlays.Chat })); } + if (!user.Equals(api.LocalUser.Value)) + items.Add(new OsuMenuItem("Report", MenuItemType.Destructive, ReportRequested)); + return items.ToArray(); } } diff --git a/osu.Game/Overlays/Chat/ReportChatPopover.cs b/osu.Game/Overlays/Chat/ReportChatPopover.cs new file mode 100644 index 0000000000..265a17c799 --- /dev/null +++ b/osu.Game/Overlays/Chat/ReportChatPopover.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.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Chat +{ + public partial class ReportChatPopover : ReportPopover + { + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private ChannelManager channelManager { get; set; } = null!; + + private readonly Message message; + + public ReportChatPopover(Message message) + : base(ReportStrings.UserTitle(message.Sender?.Username ?? @"Someone")) + { + this.message = message; + Action = report; + } + + protected override bool IsCommentRequired(ChatReportReason reason) => reason == ChatReportReason.Other; + + private void report(ChatReportReason reason, string comments) + { + var request = new ChatReportRequest(message.Id, reason, comments); + + request.Success += () => channelManager.CurrentChannel.Value.AddNewMessages(new InfoMessage(UsersStrings.ReportThanks.ToString())); + + api.Queue(request); + } + } +} diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index b44c7c48f5..96dbfe31f3 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -9,6 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Bindings; @@ -134,9 +135,13 @@ namespace osu.Game.Overlays }, Children = new Drawable[] { - currentChannelContainer = new Container + new PopoverContainer { RelativeSizeAxes = Axes.Both, + Child = currentChannelContainer = new Container + { + RelativeSizeAxes = Axes.Both, + } }, loading = new LoadingLayer(true), channelListing = new ChannelListing diff --git a/osu.Game/Overlays/Comments/CommentReportButton.cs b/osu.Game/Overlays/Comments/CommentReportButton.cs index ba5319094b..e4d4d671da 100644 --- a/osu.Game/Overlays/Comments/CommentReportButton.cs +++ b/osu.Game/Overlays/Comments/CommentReportButton.cs @@ -57,6 +57,11 @@ namespace osu.Game.Overlays.Comments link.AddLink(ReportStrings.CommentButton.ToLower(), this.ShowPopover); } + public Popover GetPopover() => new ReportCommentPopover(comment) + { + Action = report + }; + private void report(CommentReportReason reason, string comments) { var request = new CommentReportRequest(comment.Id, reason, comments); @@ -83,10 +88,5 @@ namespace osu.Game.Overlays.Comments api.Queue(request); } - - public Popover GetPopover() => new ReportCommentPopover(comment) - { - Action = report - }; } } diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index c4e4700674..24536fe460 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -99,7 +99,7 @@ namespace osu.Game.Overlays.Comments { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 50, Vertical = 20 }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 20 }, Children = new Drawable[] { avatar = new UpdateableAvatar(api.LocalUser.Value) @@ -152,7 +152,7 @@ namespace osu.Game.Overlays.Comments ShowDeleted = { BindTarget = ShowDeleted }, Margin = new MarginPadding { - Horizontal = 70, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 10 } }, @@ -393,7 +393,7 @@ namespace osu.Game.Overlays.Comments { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Left = 50 }, + Margin = new MarginPadding { Left = WaveOverlayContainer.HORIZONTAL_PADDING }, Text = CommentsStrings.Empty } }); diff --git a/osu.Game/Overlays/Comments/CommentsHeader.cs b/osu.Game/Overlays/Comments/CommentsHeader.cs index e6d44e618b..0ae1f839a1 100644 --- a/osu.Game/Overlays/Comments/CommentsHeader.cs +++ b/osu.Game/Overlays/Comments/CommentsHeader.cs @@ -41,7 +41,7 @@ namespace osu.Game.Overlays.Comments new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 50 }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING }, Children = new Drawable[] { new OverlaySortTabControl diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 397dd46cdc..a710406548 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -537,7 +537,7 @@ namespace osu.Game.Overlays.Comments { return new MarginPadding { - Horizontal = 70, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 15 }; } diff --git a/osu.Game/Overlays/Comments/ReportCommentPopover.cs b/osu.Game/Overlays/Comments/ReportCommentPopover.cs index f3b2a2f97c..e688dad755 100644 --- a/osu.Game/Overlays/Comments/ReportCommentPopover.cs +++ b/osu.Game/Overlays/Comments/ReportCommentPopover.cs @@ -1,111 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using osu.Framework.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; -using osuTK; namespace osu.Game.Overlays.Comments { - public partial class ReportCommentPopover : OsuPopover + public partial class ReportCommentPopover : ReportPopover { - public Action? Action; - - private readonly Comment? comment; - - private OsuEnumDropdown reasonDropdown = null!; - private OsuTextBox commentsTextBox = null!; - private RoundedButton submitButton = null!; - public ReportCommentPopover(Comment? comment) + : base(ReportStrings.CommentTitle(comment?.User?.Username ?? comment?.LegacyName ?? @"Someone")) { - this.comment = comment; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Child = new ReverseChildIDFillFlowContainer - { - Direction = FillDirection.Vertical, - Width = 500, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(7), - Children = new Drawable[] - { - new SpriteIcon - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Icon = FontAwesome.Solid.ExclamationTriangle, - Size = new Vector2(36), - }, - new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Text = ReportStrings.CommentTitle(comment?.User?.Username ?? comment?.LegacyName ?? @"Someone"), - Font = OsuFont.Torus.With(size: 25), - Margin = new MarginPadding { Bottom = 10 } - }, - new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Text = UsersStrings.ReportReason, - }, - new Container - { - RelativeSizeAxes = Axes.X, - Height = 40, - Child = reasonDropdown = new OsuEnumDropdown - { - RelativeSizeAxes = Axes.X - } - }, - new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Text = UsersStrings.ReportComments, - }, - commentsTextBox = new OsuTextBox - { - RelativeSizeAxes = Axes.X, - PlaceholderText = UsersStrings.ReportPlaceholder, - }, - submitButton = new RoundedButton - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Width = 200, - BackgroundColour = colours.Red3, - Text = UsersStrings.ReportActionsSend, - Action = () => - { - Action?.Invoke(reasonDropdown.Current.Value, commentsTextBox.Text); - this.HidePopover(); - }, - Margin = new MarginPadding { Bottom = 5, Top = 10 }, - } - } - }; - - commentsTextBox.Current.BindValueChanged(e => - { - submitButton.Enabled.Value = !string.IsNullOrWhiteSpace(e.NewValue); - }, true); } } } diff --git a/osu.Game/Overlays/Comments/TotalCommentsCounter.cs b/osu.Game/Overlays/Comments/TotalCommentsCounter.cs index 38928f6f3d..2065f7a76b 100644 --- a/osu.Game/Overlays/Comments/TotalCommentsCounter.cs +++ b/osu.Game/Overlays/Comments/TotalCommentsCounter.cs @@ -32,7 +32,7 @@ namespace osu.Game.Overlays.Comments Origin = Anchor.CentreLeft, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Left = 50 }, + Margin = new MarginPadding { Left = WaveOverlayContainer.HORIZONTAL_PADDING }, Spacing = new Vector2(5, 0), Children = new Drawable[] { diff --git a/osu.Game/Overlays/Comments/VotePill.cs b/osu.Game/Overlays/Comments/VotePill.cs index 6cfa5cb9e8..dd418a9e58 100644 --- a/osu.Game/Overlays/Comments/VotePill.cs +++ b/osu.Game/Overlays/Comments/VotePill.cs @@ -132,11 +132,10 @@ namespace osu.Game.Overlays.Comments }, sideNumber = new OsuSpriteText { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreRight, + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, Text = "+1", Font = OsuFont.GetFont(size: 14), - Margin = new MarginPadding { Right = 3 }, Alpha = 0, }, votesCounter = new OsuSpriteText @@ -189,7 +188,7 @@ namespace osu.Game.Overlays.Comments else sideNumber.FadeTo(IsHovered ? 1 : 0); - borderContainer.BorderThickness = IsHovered ? 3 : 0; + borderContainer.BorderThickness = IsHovered ? 2 : 0; } private void onHoverAction() diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs index 1540aa8fbb..5047992c8b 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs @@ -60,7 +60,7 @@ namespace osu.Game.Overlays.Dashboard new Container { RelativeSizeAxes = Axes.X, - Padding = new MarginPadding(padding), + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = padding }, Child = searchTextBox = new BasicSearchTextBox { RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 73fab6d62b..e3accfd2ad 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -79,7 +79,7 @@ namespace osu.Game.Overlays.Dashboard.Friends Padding = new MarginPadding { Top = 20, - Horizontal = 45 + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - FriendsOnlineStatusItem.PADDING }, Child = onlineStreamControl = new FriendOnlineStreamControl(), } @@ -129,7 +129,7 @@ namespace osu.Game.Overlays.Dashboard.Friends { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 50 } + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING } }, loading = new LoadingLayer(true) } diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 2cc8354e50..032821f215 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.cs @@ -56,6 +56,7 @@ namespace osu.Game.Overlays { Colour = Color4.Black.Opacity(0), Type = EdgeEffectType.Shadow, + Hollow = true, Radius = 10 }; @@ -101,7 +102,7 @@ namespace osu.Game.Overlays protected override void PopIn() { base.PopIn(); - FadeEdgeEffectTo(0.4f, WaveContainer.APPEAR_DURATION, Easing.Out); + FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); } protected override void PopOut() diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index 33d72ff383..d9e350e560 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -9,7 +9,6 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Database; -using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -67,7 +66,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = ModSelectOverlayStrings.AddPreset, - Action = tryCreatePreset + Action = createPreset } } }; @@ -89,16 +88,15 @@ namespace osu.Game.Overlays.Mods base.LoadComplete(); ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + + nameTextBox.Current.BindValueChanged(s => + { + createButton.Enabled.Value = !string.IsNullOrWhiteSpace(s.NewValue); + }, true); } - private void tryCreatePreset() + private void createPreset() { - if (string.IsNullOrWhiteSpace(nameTextBox.Current.Value)) - { - Body.Shake(); - return; - } - realm.Write(r => r.Add(new ModPreset { Name = nameTextBox.Current.Value, diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs new file mode 100644 index 0000000000..5220f6a391 --- /dev/null +++ b/osu.Game/Overlays/Mods/EditPresetPopover.cs @@ -0,0 +1,172 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + internal partial class EditPresetPopover : OsuPopover + { + private LabelledTextBox nameTextBox = null!; + private LabelledTextBox descriptionTextBox = null!; + private ShearedButton useCurrentModsButton = null!; + private ShearedButton saveButton = null!; + private FillFlowContainer scrollContent = null!; + + private readonly Live preset; + + private HashSet saveableMods; + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public EditPresetPopover(Live preset) + { + this.preset = preset; + saveableMods = preset.PerformRead(p => p.Mods).ToHashSet(); + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + Width = 300, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + nameTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Name, + TabbableContentContainer = this, + Current = { Value = preset.PerformRead(p => p.Name) }, + }, + descriptionTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Description, + TabbableContentContainer = this, + Current = { Value = preset.PerformRead(p => p.Description) }, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.X, + Height = 100, + 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, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Children = new Drawable[] + { + useCurrentModsButton = new ShearedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = ModSelectOverlayStrings.UseCurrentMods, + DarkerColour = colours.Blue1, + LighterColour = colours.Blue0, + TextColour = colourProvider.Background6, + Action = useCurrentMods, + }, + saveButton = new ShearedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = Resources.Localisation.Web.CommonStrings.ButtonsSave, + DarkerColour = colours.Orange1, + LighterColour = colours.Orange0, + TextColour = colourProvider.Background6, + Action = save, + }, + } + } + } + }; + + Body.BorderThickness = 3; + Body.BorderColour = colours.Orange1; + + selectedMods.BindValueChanged(_ => updateState(), true); + nameTextBox.Current.BindValueChanged(s => + { + saveButton.Enabled.Value = !string.IsNullOrWhiteSpace(s.NewValue); + }, true); + } + + private void useCurrentMods() + { + saveableMods = selectedMods.Value.ToHashSet(); + updateState(); + } + + private void updateState() + { + scrollContent.ChildrenEnumerable = saveableMods.Select(mod => new ModPresetRow(mod)); + useCurrentModsButton.Enabled.Value = checkSelectedModsDiffersFromSaved(); + } + + private bool checkSelectedModsDiffersFromSaved() + { + if (!selectedMods.Value.Any()) + return false; + + return !saveableMods.SetEquals(selectedMods.Value); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + } + + private void save() + { + preset.PerformWrite(s => + { + s.Name = nameTextBox.Current.Value; + s.Description = descriptionTextBox.Current.Value; + s.Mods = saveableMods; + }); + + this.HidePopover(); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs index 6e12e34124..8bcb5e4e4e 100644 --- a/osu.Game/Overlays/Mods/ModPresetPanel.cs +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; @@ -17,7 +18,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays.Mods { - public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip, IHasContextMenu + public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip, IHasContextMenu, IHasPopover { public readonly Live Preset; @@ -91,7 +92,8 @@ namespace osu.Game.Overlays.Mods public MenuItem[] ContextMenuItems => new MenuItem[] { - new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new DeleteModPresetDialog(Preset))) + new OsuMenuItem(CommonStrings.ButtonsEdit, MenuItemType.Highlighted, this.ShowPopover), + new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new DeleteModPresetDialog(Preset))), }; #endregion @@ -102,5 +104,7 @@ namespace osu.Game.Overlays.Mods settingChangeTracker?.Dispose(); } + + public Popover GetPopover() => new EditPresetPopover(Preset); } } diff --git a/osu.Game/Overlays/Mods/ModPresetRow.cs b/osu.Game/Overlays/Mods/ModPresetRow.cs new file mode 100644 index 0000000000..4829e93b87 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetRow.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.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; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModPresetRow : FillFlowContainer + { + public ModPresetRow(Mod mod) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + Spacing = new Vector2(4); + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7), + Children = new Drawable[] + { + new ModSwitchTiny(mod) + { + Active = { Value = true }, + Scale = new Vector2(0.6f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + 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 } + } + } + } + }; + + if (!string.IsNullOrEmpty(mod.SettingDescription)) + { + AddInternal(new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = 14 }, + Text = mod.SettingDescription + }); + } + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetTooltip.cs b/osu.Game/Overlays/Mods/ModPresetTooltip.cs index ff4f00da69..8e8259de45 100644 --- a/osu.Game/Overlays/Mods/ModPresetTooltip.cs +++ b/osu.Game/Overlays/Mods/ModPresetTooltip.cs @@ -6,11 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; using osuTK; namespace osu.Game.Overlays.Mods @@ -61,55 +57,5 @@ namespace osu.Game.Overlays.Mods protected override void PopOut() => this.FadeOut(transition_duration, Easing.OutQuint); public void Move(Vector2 pos) => Position = pos; - - private partial class ModPresetRow : FillFlowContainer - { - public ModPresetRow(Mod mod) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Direction = FillDirection.Vertical; - Spacing = new Vector2(4); - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7), - Children = new Drawable[] - { - new ModSwitchTiny(mod) - { - Active = { Value = true }, - Scale = new Vector2(0.6f), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - 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 } - } - } - } - }; - - if (!string.IsNullOrEmpty(mod.SettingDescription)) - { - AddInternal(new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 14 }, - Text = mod.SettingDescription - }); - } - } - } } } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 16602db4be..38ae8c68cb 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -242,17 +242,21 @@ namespace osu.Game.Overlays.Mods if (AllowCustomisation) ((IBindable>)modSettingsArea.SelectedMods).BindTo(SelectedMods); - SelectedMods.BindValueChanged(val => + SelectedMods.BindValueChanged(_ => { - modSettingChangeTracker?.Dispose(); - updateMultiplier(); updateFromExternalSelection(); updateCustomisation(); + modSettingChangeTracker?.Dispose(); + if (AllowCustomisation) { - modSettingChangeTracker = new ModSettingChangeTracker(val.NewValue); + // Importantly, use SelectedMods.Value here (and not the ValueChanged NewValue) as the latter can + // potentially be stale, due to complexities in the way change trackers work. + // + // See https://github.com/ppy/osu/pull/23284#issuecomment-1529056988 + modSettingChangeTracker = new ModSettingChangeTracker(SelectedMods.Value); modSettingChangeTracker.SettingChanged += _ => updateMultiplier(); } }, true); diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 00c5ce8002..90fdfd0491 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -56,7 +56,7 @@ namespace osu.Game.Overlays.Music var artist = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); titlePart = text.AddText(title, sprite => sprite.Font = OsuFont.GetFont(weight: FontWeight.Regular)); - titlePart.DrawablePartsRecreated += _ => updateSelectionState(true); + titlePart.DrawablePartsRecreated += _ => updateSelectionState(SelectedSet.Value, applyImmediately: true); text.AddText(@" "); // to separate the title from the artist. text.AddText(artist, sprite => @@ -66,27 +66,25 @@ namespace osu.Game.Overlays.Music sprite.Padding = new MarginPadding { Top = 1 }; }); - SelectedSet.BindValueChanged(set => - { - bool newSelected = set.NewValue?.Equals(Model) == true; - - if (newSelected == selected) - return; - - selected = newSelected; - updateSelectionState(false); - }); - - updateSelectionState(true); + SelectedSet.BindValueChanged(set => updateSelectionState(set.NewValue)); + updateSelectionState(SelectedSet.Value, applyImmediately: true); }); } private bool selected; - private void updateSelectionState(bool instant) + private void updateSelectionState(Live selectedSet, bool applyImmediately = false) { + bool wasSelected = selected; + selected = selectedSet?.Equals(Model) == 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) + return; + foreach (Drawable s in titlePart.Drawables) - s.FadeColour(selected ? colours.Yellow : Color4.White, instant ? 0 : FADE_DURATION); + s.FadeColour(selected ? colours.Yellow : Color4.White, applyImmediately ? 0 : FADE_DURATION); } protected override Drawable CreateContent() => new DelayedLoadWrapper(text = new OsuTextFlowContainer diff --git a/osu.Game/Overlays/News/Displays/ArticleListing.cs b/osu.Game/Overlays/News/Displays/ArticleListing.cs index b6ce16ae7d..4fc9dde156 100644 --- a/osu.Game/Overlays/News/Displays/ArticleListing.cs +++ b/osu.Game/Overlays/News/Displays/ArticleListing.cs @@ -43,7 +43,7 @@ namespace osu.Game.Overlays.News.Displays { Vertical = 20, Left = 30, - Right = 50 + Right = WaveOverlayContainer.HORIZONTAL_PADDING }; InternalChild = new FillFlowContainer diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index e0be5cc4a9..b12aa4509e 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.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 osu.Framework.Allocation; @@ -13,7 +11,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -27,8 +24,8 @@ namespace osu.Game.Overlays.News private readonly APINewsPost post; - private Box background; - private TextFlowContainer main; + private Box background = null!; + private TextFlowContainer main = null!; public NewsCard(APINewsPost post) { @@ -41,12 +38,12 @@ namespace osu.Game.Overlays.News } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, GameHost host) + private void load(OverlayColourProvider colourProvider, OsuGame? game) { if (post.Slug != null) { TooltipText = "view in browser"; - Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + Action = () => game?.OpenUrlExternally(@"/home/news/" + post.Slug); } AddRange(new Drawable[] diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs index 30d29048ba..9a748b2001 100644 --- a/osu.Game/Overlays/News/Sidebar/MonthSection.cs +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -20,7 +20,7 @@ using System.Diagnostics; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Platform; +using osu.Game.Online.Chat; namespace osu.Game.Overlays.News.Sidebar { @@ -59,7 +59,7 @@ namespace osu.Game.Overlays.News.Sidebar new PostsContainer { Expanded = { BindTarget = Expanded }, - Children = posts.Select(p => new PostButton(p)).ToArray() + Children = posts.Select(p => new PostLink(p)).ToArray() } } }; @@ -123,35 +123,14 @@ namespace osu.Game.Overlays.News.Sidebar } } - private partial class PostButton : OsuHoverContainer + private partial class PostLink : LinkFlowContainer { - protected override IEnumerable EffectTargets => new[] { text }; - - private readonly TextFlowContainer text; - private readonly APINewsPost post; - - public PostButton(APINewsPost post) + public PostLink(APINewsPost post) + : base(t => t.Font = OsuFont.GetFont(size: 12)) { - this.post = post; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Child = text = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Text = post.Title - }; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider overlayColours, GameHost host) - { - IdleColour = overlayColours.Light2; - HoverColour = overlayColours.Light1; - - TooltipText = "view in browser"; - Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + AddLink(post.Title, LinkAction.External, @"/home/news/" + post.Slug, "view in browser"); } } diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index 71a4c58afd..f2eefb6e4b 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -5,9 +5,11 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Logging; @@ -16,6 +18,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Overlays.Notifications; using osu.Game.Resources.Localisation.Web; using osuTK; +using osuTK.Graphics; using NotificationsStrings = osu.Game.Localisation.NotificationsStrings; namespace osu.Game.Overlays @@ -72,6 +75,14 @@ namespace osu.Game.Overlays mainContent = new Container { RelativeSizeAxes = Axes.Both, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0), + Type = EdgeEffectType.Shadow, + Radius = 10, + Hollow = true, + }, Children = new Drawable[] { new Box @@ -199,6 +210,7 @@ namespace osu.Game.Overlays this.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); mainContent.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); toastTray.FlushAllToasts(); } @@ -211,6 +223,7 @@ namespace osu.Game.Overlays this.MoveToX(WIDTH, TRANSITION_LENGTH, Easing.OutQuint); mainContent.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.In); } private void notificationClosed() => Schedule(() => diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs index 4fdf7cb2b6..8b7a82f899 100644 --- a/osu.Game/Overlays/OnlineOverlay.cs +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -22,6 +23,7 @@ namespace osu.Game.Overlays protected readonly OverlayScrollContainer ScrollFlow; protected readonly LoadingLayer Loading; + private readonly Container loadingContainer; private readonly Container content; protected OnlineOverlay(OverlayColourScheme colourScheme, bool requiresSignIn = true) @@ -65,10 +67,30 @@ namespace osu.Game.Overlays }, } }, - Loading = new LoadingLayer(true) + loadingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = Loading = new LoadingLayer(true), + } }); base.Content.Add(mainContent); } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Ensure the scroll-to-top button is displayed above the fixed header. + AddInternal(ScrollFlow.Button.CreateProxy()); + } + + protected override void UpdateAfterChildren() + { + 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) }; + } } } diff --git a/osu.Game/Overlays/OverlayHeader.cs b/osu.Game/Overlays/OverlayHeader.cs index f28d40c429..93de463204 100644 --- a/osu.Game/Overlays/OverlayHeader.cs +++ b/osu.Game/Overlays/OverlayHeader.cs @@ -89,7 +89,7 @@ namespace osu.Game.Overlays } }); - ContentSidePadding = 50; + ContentSidePadding = WaveOverlayContainer.HORIZONTAL_PADDING; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 9752e04f44..9ff0a65652 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -31,7 +31,7 @@ namespace osu.Game.Overlays /// private const int button_scroll_position = 200; - protected ScrollBackButton Button; + public ScrollBackButton Button { get; private set; } private readonly Bindable lastScrollTarget = new Bindable(); @@ -185,6 +185,12 @@ namespace osu.Game.Overlays content.ScaleTo(1, 1000, Easing.OutElastic); base.OnMouseUp(e); } + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + return true; + } } } } diff --git a/osu.Game/Overlays/OverlaySidebar.cs b/osu.Game/Overlays/OverlaySidebar.cs index b8c0032e87..93e5e83ffc 100644 --- a/osu.Game/Overlays/OverlaySidebar.cs +++ b/osu.Game/Overlays/OverlaySidebar.cs @@ -55,7 +55,7 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Vertical = 20, - Left = 50, + Left = WaveOverlayContainer.HORIZONTAL_PADDING, Right = 30 }, Child = CreateContent() diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs index 8af2ab3823..5c51f5e4d0 100644 --- a/osu.Game/Overlays/OverlaySortTabControl.cs +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -117,7 +117,7 @@ namespace osu.Game.Overlays } } - protected partial class TabButton : HeaderButton + public partial class TabButton : HeaderButton { public readonly BindableBool Active = new BindableBool(); diff --git a/osu.Game/Overlays/OverlayStreamItem.cs b/osu.Game/Overlays/OverlayStreamItem.cs index 9b18e5cccf..45181c13e4 100644 --- a/osu.Game/Overlays/OverlayStreamItem.cs +++ b/osu.Game/Overlays/OverlayStreamItem.cs @@ -39,12 +39,14 @@ namespace osu.Game.Overlays private FillFlowContainer text; private ExpandingBar expandingBar; + public const float PADDING = 5; + protected OverlayStreamItem(T value) : base(value) { Height = 50; Width = 90; - Margin = new MarginPadding(5); + Margin = new MarginPadding(PADDING); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Profile/Header/BadgeHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BadgeHeaderContainer.cs index 508041eb76..24be6ce2f5 100644 --- a/osu.Game/Overlays/Profile/Header/BadgeHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BadgeHeaderContainer.cs @@ -50,7 +50,7 @@ namespace osu.Game.Overlays.Profile.Header RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Spacing = new Vector2(10, 10), - Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, Top = 10 }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Top = 10 }, } }; } diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index 1e80257a57..08a816930e 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -55,7 +55,7 @@ namespace osu.Game.Overlays.Profile.Header RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, Vertical = 10 }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 10 }, Spacing = new Vector2(0, 10), Children = new Drawable[] { diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 0dab4d582d..d964364510 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -39,7 +39,7 @@ namespace osu.Game.Overlays.Profile.Header RelativeSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, Padding = new MarginPadding { Vertical = 10 }, - Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }, + Margin = new MarginPadding { Left = WaveOverlayContainer.HORIZONTAL_PADDING }, Spacing = new Vector2(10, 0), Children = new Drawable[] { @@ -62,7 +62,7 @@ namespace osu.Game.Overlays.Profile.Header Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Right = UserProfileOverlay.CONTENT_X_MARGIN }, + Margin = new MarginPadding { Right = WaveOverlayContainer.HORIZONTAL_PADDING }, Children = new Drawable[] { levelBadge = new LevelBadge @@ -77,7 +77,7 @@ namespace osu.Game.Overlays.Profile.Header Origin = Anchor.CentreRight, Width = 200, Height = 6, - Margin = new MarginPadding { Right = 50 }, + Margin = new MarginPadding { Right = WaveOverlayContainer.HORIZONTAL_PADDING }, Child = new LevelProgressBar { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs index 1cc3aae735..1f35f39b49 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -30,7 +30,7 @@ namespace osu.Game.Overlays.Profile.Header { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, Vertical = 10 }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 10 }, Child = new GridContainer { RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 2f4f49788f..de678cb5d1 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; 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.Online.API; @@ -31,6 +32,9 @@ namespace osu.Game.Overlays.Profile.Header [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private RankingsOverlay? rankingsOverlay { get; set; } + private UserCoverBackground cover = null!; private SupporterIcon supporterTag = null!; private UpdateableAvatar avatar = null!; @@ -38,6 +42,7 @@ namespace osu.Game.Overlays.Profile.Header private ExternalLinkButton openUserExternally = null!; private OsuSpriteText titleText = null!; private UpdateableFlag userFlag = null!; + private OsuHoverContainer userCountryContainer = null!; private OsuSpriteText userCountryText = null!; private GroupBadgeFlow groupBadgeFlow = null!; private ToggleCoverButton coverToggle = null!; @@ -83,7 +88,7 @@ namespace osu.Game.Overlays.Profile.Header Direction = FillDirection.Horizontal, Padding = new MarginPadding { - Left = UserProfileOverlay.CONTENT_X_MARGIN, + Left = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = vertical_padding }, Height = content_height + 2 * vertical_padding, @@ -156,13 +161,17 @@ namespace osu.Game.Overlays.Profile.Header Size = new Vector2(28, 20), ShowPlaceholderOnUnknown = false, }, - userCountryText = new OsuSpriteText + userCountryContainer = new OsuHoverContainer { - Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), - Margin = new MarginPadding { Left = 5 }, - Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, - } + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 5 }, + Child = userCountryText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + }, + }, } }, } @@ -202,6 +211,7 @@ namespace osu.Game.Overlays.Profile.Header openUserExternally.Link = $@"{api.WebsiteRootUrl}/users/{user?.Id ?? 0}"; userFlag.CountryCode = user?.CountryCode ?? default; userCountryText.Text = (user?.CountryCode ?? default).GetDescription(); + userCountryContainer.Action = () => rankingsOverlay?.ShowCountry(user?.CountryCode ?? default); 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/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 363eb5d58e..80d48ae09e 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -21,7 +21,7 @@ namespace osu.Game.Overlays.Profile public ProfileHeader() { - ContentSidePadding = UserProfileOverlay.CONTENT_X_MARGIN; + ContentSidePadding = WaveOverlayContainer.HORIZONTAL_PADDING; TabControl.AddItem(LayoutStrings.HeaderUsersShow); diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index 4ac86924f8..a8a240ddde 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -67,7 +67,7 @@ namespace osu.Game.Overlays.Profile AutoSizeAxes = Axes.Both, Margin = new MarginPadding { - Horizontal = UserProfileOverlay.CONTENT_X_MARGIN - outer_gutter_width, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - outer_gutter_width, Top = 20, Bottom = 20, }, @@ -97,7 +97,7 @@ namespace osu.Game.Overlays.Profile RelativeSizeAxes = Axes.X, Padding = new MarginPadding { - Horizontal = UserProfileOverlay.CONTENT_X_MARGIN - outer_gutter_width, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - outer_gutter_width, Bottom = 20 } }, diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index d2f01ef9f7..1a44262ef8 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -2,15 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Game.Resources.Localisation.Web; using osu.Framework.Localisation; @@ -52,36 +49,24 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu { private readonly OsuSpriteText valueText; protected readonly LinkFlowContainer DescriptionText; - private readonly Box lineBackground; public new int Count { set => valueText.Text = value.ToLocalisableString("N0"); } - public CountSection(LocalisableString header) + protected CountSection(LocalisableString header) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Top = 10, Bottom = 20 }; + Padding = new MarginPadding { Bottom = 20 }; Child = new FillFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), Children = new Drawable[] { - new CircularContainer - { - Masking = true, - RelativeSizeAxes = Axes.X, - Height = 2, - Child = lineBackground = new Box - { - RelativeSizeAxes = Axes.Both, - } - }, new OsuSpriteText { Text = header, @@ -91,7 +76,6 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu { Text = "0", Font = OsuFont.GetFont(size: 40, weight: FontWeight.Light), - UseFullGlyphHeight = false, }, DescriptionText = new LinkFlowContainer(t => t.Font = t.Font.With(size: 14)) { @@ -101,12 +85,6 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu } }; } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - lineBackground.Colour = colourProvider.Highlight1; - } } } } diff --git a/osu.Game/Overlays/Rankings/CountryFilter.cs b/osu.Game/Overlays/Rankings/CountryFilter.cs index e27fa7c7bd..525816f8fd 100644 --- a/osu.Game/Overlays/Rankings/CountryFilter.cs +++ b/osu.Game/Overlays/Rankings/CountryFilter.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Rankings Origin = Anchor.CentreLeft, Direction = FillDirection.Horizontal, Spacing = new Vector2(10, 0), - Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }, + Margin = new MarginPadding { Left = WaveOverlayContainer.HORIZONTAL_PADDING }, Children = new Drawable[] { new OsuSpriteText diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index 31273e3b01..190da04a5d 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -63,7 +63,7 @@ namespace osu.Game.Overlays.Rankings { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING }, Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index affd9a2c44..27d894cdc2 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -23,7 +23,6 @@ namespace osu.Game.Overlays.Rankings.Tables public abstract partial class RankingsTable : TableContainer { protected const int TEXT_SIZE = 12; - private const float horizontal_inset = 20; private const float row_height = 32; private const float row_spacing = 3; private const int items_per_page = 50; @@ -39,7 +38,7 @@ namespace osu.Game.Overlays.Rankings.Tables RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Horizontal = horizontal_inset }; + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING }; RowSize = new Dimension(GridSizeMode.Absolute, row_height + row_spacing); } diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index c5274d6223..f4a79d65e6 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.Settings.Sections new SettingsButton { Text = GeneralSettingsStrings.RunSetupWizard, - Keywords = new[] { @"first run", @"initial", @"getting started" }, + Keywords = new[] { @"first run", @"initial", @"getting started", @"import", @"tutorial", @"recommended beatmaps" }, TooltipText = FirstRunSetupOverlayStrings.FirstRunSetupDescription, Action = () => firstRunSetupOverlay?.Show(), }, diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 6465d62ef0..a3290bc81c 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -30,9 +30,9 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics protected override LocalisableString Header => GraphicsSettingsStrings.LayoutHeader; private FillFlowContainer> scalingSettings = null!; + private SettingsSlider dimSlider = null!; private readonly Bindable currentDisplay = new Bindable(); - private readonly IBindableList windowModes = new BindableList(); private Bindable scalingMode = null!; private Bindable sizeFullscreen = null!; @@ -58,6 +58,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private Bindable scalingSizeX = null!; private Bindable scalingSizeY = null!; + private Bindable scalingBackgroundDim = null!; + private const int transition_duration = 400; [BackgroundDependencyLoader] @@ -71,11 +73,11 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingSizeY = osuConfig.GetBindable(OsuSetting.ScalingSizeY); scalingPositionX = osuConfig.GetBindable(OsuSetting.ScalingPositionX); scalingPositionY = osuConfig.GetBindable(OsuSetting.ScalingPositionY); + scalingBackgroundDim = osuConfig.GetBindable(OsuSetting.ScalingBackgroundDim); if (window != null) { currentDisplay.BindTo(window.CurrentDisplayBindable); - windowModes.BindTo(window.SupportedWindowModes); window.DisplaysChanged += onDisplaysChanged; } @@ -87,7 +89,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics windowModeDropdown = new SettingsDropdown { LabelText = GraphicsSettingsStrings.ScreenMode, - ItemSource = windowModes, + Items = window?.SupportedWindowModes, + CanBeShown = { Value = window?.SupportedWindowModes.Count() > 1 }, Current = config.GetBindable(FrameworkSetting.WindowMode), }, displayDropdown = new DisplaySettingsDropdown @@ -162,6 +165,13 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics KeyboardStep = 0.01f, DisplayAsPercentage = true }, + dimSlider = new SettingsSlider + { + LabelText = GameplaySettingsStrings.BackgroundDim, + Current = scalingBackgroundDim, + KeyboardStep = 0.01f, + DisplayAsPercentage = true, + }, } }, }; @@ -181,21 +191,20 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics updateScreenModeWarning(); }, true); - windowModes.BindCollectionChanged((_, _) => updateDisplaySettingsVisibility()); - currentDisplay.BindValueChanged(display => Schedule(() => { - resolutions.RemoveRange(1, resolutions.Count - 1); - - if (display.NewValue != null) + if (display.NewValue == null) { - resolutions.AddRange(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()); + resolutions.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()); + updateDisplaySettingsVisibility(); }), true); @@ -219,8 +228,15 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingSettings.AutoSizeAxes = scalingMode.Value != ScalingMode.Off ? Axes.Y : Axes.None; scalingSettings.ForEach(s => { - s.TransferValueOnCommit = scalingMode.Value == ScalingMode.Everything; - s.CanBeShown.Value = scalingMode.Value != ScalingMode.Off; + if (s == dimSlider) + { + s.CanBeShown.Value = scalingMode.Value == ScalingMode.Everything || scalingMode.Value == ScalingMode.ExcludeOverlays; + } + else + { + s.TransferValueOnCommit = scalingMode.Value == ScalingMode.Everything; + s.CanBeShown.Value = scalingMode.Value != ScalingMode.Off; + } }); } } @@ -229,14 +245,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { Scheduler.AddOnce(d => { - displayDropdown.Items = d; + if (!displayDropdown.Items.SequenceEqual(d, DisplayListComparer.DEFAULT)) + displayDropdown.Items = d; updateDisplaySettingsVisibility(); }, displays); } private void updateDisplaySettingsVisibility() { - windowModeDropdown.CanBeShown.Value = windowModes.Count > 1; resolutionDropdown.CanBeShown.Value = resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen; displayDropdown.CanBeShown.Value = displayDropdown.Items.Count() > 1; safeAreaConsiderationsCheckbox.CanBeShown.Value = host.Window?.SafeAreaPadding.Value.Total != Vector2.Zero; @@ -260,7 +276,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics return; } - if (host.Window is WindowsWindow) + if (host.Renderer is IWindowsRenderer) { switch (fullscreenCapability.Value) { @@ -362,5 +378,43 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics } } } + + /// + /// Contrary to , this comparer disregards the value of . + /// We want to just show a list of displays, and for the purposes of settings we don't care about their bounds when it comes to the list. + /// However, fires even if only the resolution of the current display was changed + /// (because it causes the bounds of all displays to also change). + /// We're not interested in those changes, so compare only the rest that we actually care about. + /// This helps to avoid a bindable/event feedback loop, in which a resolution change + /// would trigger a display "change", which would in turn reset resolution again. + /// + private class DisplayListComparer : IEqualityComparer + { + public static readonly DisplayListComparer DEFAULT = new DisplayListComparer(); + + public bool Equals(Display? x, Display? y) + { + if (ReferenceEquals(x, y)) return true; + if (ReferenceEquals(x, null)) return false; + if (ReferenceEquals(y, null)) return false; + + return x.Index == y.Index + && x.Name == y.Name + && x.DisplayModes.SequenceEqual(y.DisplayModes); + } + + public int GetHashCode(Display obj) + { + var hashCode = new HashCode(); + + hashCode.Add(obj.Index); + hashCode.Add(obj.Name); + hashCode.Add(obj.DisplayModes.Length); + foreach (var displayMode in obj.DisplayModes) + hashCode.Add(displayMode); + + return hashCode.ToHashCode(); + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index a5fdfdc105..a1f728ca87 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -1,15 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Linq; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Configuration; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; namespace osu.Game.Overlays.Settings.Sections.Graphics { @@ -17,12 +20,25 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { protected override LocalisableString Header => GraphicsSettingsStrings.RendererHeader; + private bool automaticRendererInUse; + [BackgroundDependencyLoader] - private void load(FrameworkConfigManager config, OsuConfigManager osuConfig) + private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, IDialogOverlay? dialogOverlay, OsuGame? game, GameHost host) { - // NOTE: Compatability mode omitted + var renderer = config.GetBindable(FrameworkSetting.Renderer); + automaticRendererInUse = renderer.Value == RendererType.Automatic; + + SettingsEnumDropdown rendererDropdown; + Children = new Drawable[] { + rendererDropdown = new RendererSettingsDropdown + { + LabelText = GraphicsSettingsStrings.Renderer, + Current = renderer, + Items = host.GetPreferredRenderersForCurrentPlatform().OrderBy(t => t).Where(t => t != RendererType.Vulkan), + Keywords = new[] { @"compatibility", @"directx" }, + }, // TODO: this needs to be a custom dropdown at some point new SettingsEnumDropdown { @@ -41,6 +57,55 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics Current = osuConfig.GetBindable(OsuSetting.ShowFpsDisplay) }, }; + + renderer.BindValueChanged(r => + { + if (r.NewValue == host.ResolvedRenderer) + return; + + // Need to check startup renderer for the "automatic" case, as ResolvedRenderer above will track the final resolved renderer instead. + if (r.NewValue == RendererType.Automatic && automaticRendererInUse) + return; + + dialogOverlay?.Push(new ConfirmDialog(GraphicsSettingsStrings.ChangeRendererConfirmation, () => game?.AttemptExit(), () => + { + renderer.Value = automaticRendererInUse ? RendererType.Automatic : host.ResolvedRenderer; + })); + }); + + // TODO: remove this once we support SDL+android. + if (RuntimeInfo.OS == RuntimeInfo.Platform.Android) + { + rendererDropdown.Items = new[] { RendererType.Automatic, RendererType.OpenGLLegacy }; + rendererDropdown.SetNoticeText("New renderer support for android is coming soon!", true); + } + } + + private partial class RendererSettingsDropdown : SettingsEnumDropdown + { + protected override OsuDropdown CreateDropdown() => new RendererDropdown(); + + protected partial class RendererDropdown : DropdownControl + { + private RendererType hostResolvedRenderer; + private bool automaticRendererInUse; + + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager config, GameHost host) + { + var renderer = config.GetBindable(FrameworkSetting.Renderer); + automaticRendererInUse = renderer.Value == RendererType.Automatic; + hostResolvedRenderer = host.ResolvedRenderer; + } + + protected override LocalisableString GenerateItemText(RendererType item) + { + if (item == RendererType.Automatic && automaticRendererInUse) + return LocalisableString.Interpolate($"{base.GenerateItemText(item)} ({hostResolvedRenderer.GetDescription()})"); + + return base.GenerateItemText(item); + } + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 951cf3802f..4c9320c2a6 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -3,6 +3,8 @@ #nullable disable +using System.Collections.Generic; +using System.Linq; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -23,6 +25,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public partial class TabletSettings : SettingsSubsection { + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "area" }); + public TabletAreaSelection AreaSelection { get; private set; } private readonly ITabletHandler tabletHandler; diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 5cf8157812..5382eac675 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; -using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; @@ -139,9 +138,6 @@ namespace osu.Game.Overlays.Settings.Sections [Resolved] private SkinManager skins { get; set; } - [Resolved] - private Storage storage { get; set; } - private Bindable currentSkin; [BackgroundDependencyLoader] @@ -163,7 +159,7 @@ namespace osu.Game.Overlays.Settings.Sections { try { - currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s)); + skins.ExportCurrentSkin(); } catch (Exception e) { diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 784f20a6e8..eda18abaef 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -10,12 +10,10 @@ using osu.Game.Graphics.Sprites; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Localisation; -using osu.Framework.Testing; using osu.Game.Graphics; namespace osu.Game.Overlays.Settings { - [ExcludeFromDynamicCompile] public abstract partial class SettingsSubsection : FillFlowContainer, IFilterable { protected override Container Content => FlowContent; diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index aefaccdb5d..d571557993 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -10,15 +10,18 @@ using System.Threading.Tasks; using osuTK; 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.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; +using osuTK.Graphics; namespace osu.Game.Overlays { @@ -105,6 +108,14 @@ namespace osu.Game.Overlays Add(SectionsContainer = new SettingsSectionsContainer { Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0), + Type = EdgeEffectType.Shadow, + Hollow = true, + Radius = 10 + }, + MaskingSmoothness = 0, RelativeSizeAxes = Axes.Both, ExpandableHeader = CreateHeader(), SelectedSection = { BindTarget = CurrentSection }, @@ -156,6 +167,8 @@ namespace osu.Game.Overlays ContentContainer.MoveToX(ExpandedPosition, TRANSITION_LENGTH, Easing.OutQuint); + SectionsContainer.FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); + // delay load enough to ensure it doesn't overlap with the initial animation. // this is done as there is still a brief stutter during load completion which is more visible if the transition is in progress. // the eventual goal would be to remove the need for this by splitting up load into smaller work pieces, or fixing the remaining @@ -175,6 +188,7 @@ namespace osu.Game.Overlays { base.PopOut(); + SectionsContainer.FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.In); ContentContainer.MoveToX(-WIDTH + ExpandedPosition, TRANSITION_LENGTH, Easing.OutQuint); Sidebar?.MoveToX(-sidebar_width, TRANSITION_LENGTH, Easing.OutQuint); diff --git a/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs index 3f8d9f80d4..db27e20010 100644 --- a/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs +++ b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs @@ -25,6 +25,8 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private SkinEditor editor { get; set; } = null!; + protected override bool AllowCyclicSelection => true; + public SkinBlueprintContainer(ISerialisableDrawableContainer targetContainer) { this.targetContainer = targetContainer; diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs index d1a1850796..673ba873c4 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -56,20 +57,53 @@ namespace osu.Game.Overlays.SkinEditor if (deserializedContent == null) return; - SerialisedDrawableInfo[] skinnableInfo = deserializedContent.ToArray(); - Drawable[] targetComponents = firstTarget.Components.OfType().ToArray(); + SerialisedDrawableInfo[] skinnableInfos = deserializedContent.ToArray(); + ISerialisableDrawable[] targetComponents = firstTarget.Components.ToArray(); - if (!skinnableInfo.Select(s => s.Type).SequenceEqual(targetComponents.Select(d => d.GetType()))) + // Store components based on type for later reuse + var componentsPerTypeLookup = new Dictionary>(); + + foreach (ISerialisableDrawable component in targetComponents) { - // Perform a naive full reload for now. - firstTarget.Reload(skinnableInfo); + Type lookup = component.GetType(); + + if (!componentsPerTypeLookup.TryGetValue(lookup, out Queue? componentsOfSameType)) + componentsPerTypeLookup.Add(lookup, componentsOfSameType = new Queue()); + + componentsOfSameType.Enqueue((Drawable)component); } - else - { - int i = 0; - foreach (var drawable in targetComponents) - drawable.ApplySerialisedInfo(skinnableInfo[i++]); + for (int i = targetComponents.Length - 1; i >= 0; i--) + firstTarget.Remove(targetComponents[i], false); + + foreach (var skinnableInfo in skinnableInfos) + { + Type lookup = skinnableInfo.Type; + + if (!componentsPerTypeLookup.TryGetValue(lookup, out Queue? componentsOfSameType)) + { + firstTarget.Add((ISerialisableDrawable)skinnableInfo.CreateInstance()); + continue; + } + + // Wherever possible, attempt to reuse existing component instances. + if (componentsOfSameType.TryDequeue(out Drawable? component)) + { + component.ApplySerialisedInfo(skinnableInfo); + } + else + { + component = skinnableInfo.CreateInstance(); + } + + firstTarget.Add((ISerialisableDrawable)component); + } + + // Dispose components which were not reused. + foreach ((Type _, Queue typeComponents) in componentsPerTypeLookup) + { + foreach (var component in typeComponents) + component.Dispose(); } } } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs index 61195d7175..9b021632cf 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs @@ -113,7 +113,7 @@ namespace osu.Game.Overlays.SkinEditor if (replayGeneratingMod != null) screen.Push(new PlayerLoader(() => new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)))); - }, new[] { typeof(Player), typeof(SongSelect) }) + }, new[] { typeof(Player), typeof(PlaySongSelect) }) }, } }, diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index f21ef0ee98..93294a9d30 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Toolbar protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); // Toolbar and its components need keyboard input even when hidden. - public override bool PropagateNonPositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => OverlayActivationMode.Value != OverlayActivation.Disabled; public Toolbar() { diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index c5f8a820ea..ab4f07b982 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -45,8 +45,6 @@ namespace osu.Game.Overlays [Resolved] private RulesetStore rulesets { get; set; } = null!; - public const float CONTENT_X_MARGIN = 50; - public UserProfileOverlay() : base(OverlayColourScheme.Pink) { @@ -184,7 +182,7 @@ namespace osu.Game.Overlays public ProfileSectionTabControl() { Height = 40; - Padding = new MarginPadding { Horizontal = CONTENT_X_MARGIN }; + Padding = new MarginPadding { Horizontal = HORIZONTAL_PADDING }; TabContainer.Spacing = new Vector2(20); } @@ -251,12 +249,14 @@ namespace osu.Game.Overlays private partial class ProfileSectionsContainer : SectionsContainer { + private OverlayScrollContainer scroll = null!; + public ProfileSectionsContainer() { RelativeSizeAxes = Axes.Both; } - protected override UserTrackingScrollContainer CreateScrollContainer() => new OverlayScrollContainer(); + protected override UserTrackingScrollContainer CreateScrollContainer() => scroll = new OverlayScrollContainer(); // Reverse child ID is required so expanding beatmap panels can appear above sections below them. // This can also be done by setting Depth when adding new sections above if using ReverseChildID turns out to have any issues. @@ -269,6 +269,14 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Horizontal = 10 }, Margin = new MarginPadding { Bottom = 10 }, }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Ensure the scroll-to-top button is displayed above the fixed header. + AddInternal(scroll.Button.CreateProxy()); + } } } } diff --git a/osu.Game/Overlays/WaveOverlayContainer.cs b/osu.Game/Overlays/WaveOverlayContainer.cs index d25f6a9ae5..00474cc0d8 100644 --- a/osu.Game/Overlays/WaveOverlayContainer.cs +++ b/osu.Game/Overlays/WaveOverlayContainer.cs @@ -22,6 +22,8 @@ namespace osu.Game.Overlays protected override string PopInSampleName => "UI/wave-pop-in"; + public const float HORIZONTAL_PADDING = 50; + protected WaveOverlayContainer() { AddInternal(Waves = new WaveContainer diff --git a/osu.Game/Overlays/Wiki/WikiArticlePage.cs b/osu.Game/Overlays/Wiki/WikiArticlePage.cs index 6c1dbe3181..342a395871 100644 --- a/osu.Game/Overlays/Wiki/WikiArticlePage.cs +++ b/osu.Game/Overlays/Wiki/WikiArticlePage.cs @@ -56,7 +56,7 @@ namespace osu.Game.Overlays.Wiki { Vertical = 20, Left = 30, - Right = 50, + Right = WaveOverlayContainer.HORIZONTAL_PADDING, }, OnAddHeading = sidebar.AddEntry, } diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 88dc2cd7a4..c816eca776 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -145,7 +145,7 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Vertical = 20, - Horizontal = 50, + Horizontal = HORIZONTAL_PADDING, }, }); } @@ -157,7 +157,9 @@ namespace osu.Game.Overlays private void onFail(string originalPath) { + wikiData.Value = null; path.Value = "error"; + LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page](Main_Page).")); } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 8dd1b51cae..00c90bd317 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -137,7 +137,7 @@ namespace osu.Game.Rulesets.Difficulty foreach (var combination in CreateDifficultyAdjustmentModCombinations()) { - Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic); + Mod classicMod = rulesetInstance.CreateMod(); var finalCombination = ModUtils.FlattenMod(combination); if (classicMod != null) diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs index 4f802a22a1..64a04f896f 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs @@ -62,11 +62,13 @@ namespace osu.Game.Rulesets.Difficulty .GroupBy(hr => hr, (hr, list) => (hitResult: hr, count: list.Count())) .ToDictionary(pair => pair.hitResult, pair => pair.count); perfectPlay.Statistics = statistics; + perfectPlay.MaximumStatistics = statistics; // calculate total score ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = perfectPlay.Mods; - perfectPlay.TotalScore = scoreProcessor.ComputeScore(ScoringMode.Standardised, perfectPlay); + scoreProcessor.ApplyBeatmap(playableBeatmap); + perfectPlay.TotalScore = scoreProcessor.MaximumTotalScore; // compute rank achieved // default to SS, then adjust the rank with mods diff --git a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs index 5b59a81f91..a2ae1764dd 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Edit.Checks yield break; // Samples that allow themselves to be overridden by control points have a volume of 0. - int maxVolume = sampledHitObject.Samples.Max(sample => sample.Volume > 0 ? sample.Volume : sampledHitObject.SampleControlPoint.SampleVolume); + int maxVolume = sampledHitObject.Samples.Max(sample => sample.Volume); double samplePlayTime = sampledHitObject.GetEndTime(); EdgeType edgeType = getEdgeAtTime(hitObject, samplePlayTime); diff --git a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs index 0df481737e..a8972775de 100644 --- a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs @@ -11,8 +11,6 @@ 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.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -25,6 +23,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.OSD; using osu.Game.Overlays.Settings.Sections; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Components.TernaryButtons; namespace osu.Game.Rulesets.Edit @@ -47,8 +46,6 @@ namespace osu.Game.Rulesets.Edit IBindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; - protected ExpandingToolboxContainer RightSideToolboxContainer { get; private set; } - private ExpandableSlider> distanceSpacingSlider; private ExpandableButton currentDistanceSpacingButton; @@ -67,47 +64,29 @@ namespace osu.Game.Rulesets.Edit [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - AddInternal(new Container + RightToolbox.Add(new EditorToolboxGroup("snapping") { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, + Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Children = new Drawable[] { - new Box + distanceSpacingSlider = new ExpandableSlider> { - Colour = colourProvider.Background5, - RelativeSizeAxes = Axes.Both, + KeyboardStep = adjust_step, + // Manual binding in LoadComplete to handle one-way event flow. + Current = DistanceSpacingMultiplier.GetUnboundCopy(), }, - RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250) + currentDistanceSpacingButton = new ExpandableButton { - Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, - Child = new EditorToolboxGroup("snapping") + Action = () => { - Children = new Drawable[] - { - distanceSpacingSlider = new ExpandableSlider> - { - KeyboardStep = adjust_step, - // Manual binding in LoadComplete to handle one-way event flow. - Current = DistanceSpacingMultiplier.GetUnboundCopy(), - }, - currentDistanceSpacingButton = new ExpandableButton - { - Action = () => - { - (HitObject before, HitObject after)? objects = getObjectsOnEitherSideOfCurrentTime(); + (HitObject before, HitObject after)? objects = getObjectsOnEitherSideOfCurrentTime(); - Debug.Assert(objects != null); + Debug.Assert(objects != null); - DistanceSpacingMultiplier.Value = ReadCurrentDistanceSnap(objects.Value.before, objects.Value.after); - DistanceSnapToggle.Value = TernaryState.True; - }, - RelativeSizeAxes = Axes.X, - } - } - } + DistanceSpacingMultiplier.Value = ReadCurrentDistanceSnap(objects.Value.before, objects.Value.after); + DistanceSnapToggle.Value = TernaryState.True; + }, + RelativeSizeAxes = Axes.X, } } }); @@ -115,7 +94,7 @@ namespace osu.Game.Rulesets.Edit private (HitObject before, HitObject after)? getObjectsOnEitherSideOfCurrentTime() { - HitObject lastBefore = Playfield.HitObjectContainer.AliveObjects.LastOrDefault(h => h.HitObject.StartTime <= EditorClock.CurrentTime)?.HitObject; + HitObject lastBefore = Playfield.HitObjectContainer.AliveObjects.LastOrDefault(h => h.HitObject.StartTime < EditorClock.CurrentTime)?.HitObject; if (lastBefore == null) return null; @@ -261,7 +240,8 @@ namespace osu.Game.Rulesets.Edit public virtual float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) { - return (float)(100 * (useReferenceSliderVelocity ? referenceObject.DifficultyControlPoint.SliderVelocity : 1) * EditorBeatmap.Difficulty.SliderMultiplier * 1 / BeatSnapProvider.BeatDivisor); + return (float)(100 * (useReferenceSliderVelocity && referenceObject is IHasSliderVelocity hasSliderVelocity ? hasSliderVelocity.SliderVelocity : 1) * EditorBeatmap.Difficulty.SliderMultiplier * 1 + / BeatSnapProvider.BeatDivisor); } public virtual float DurationToDistance(HitObject referenceObject, double duration) diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index aee86fd942..6fcb8f62ee 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -58,8 +58,15 @@ namespace osu.Game.Rulesets.Edit [Resolved] protected IBeatSnapProvider BeatSnapProvider { get; private set; } + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + protected ComposeBlueprintContainer BlueprintContainer { get; private set; } + protected ExpandingToolboxContainer LeftToolbox { get; private set; } + + protected ExpandingToolboxContainer RightToolbox { get; private set; } + private DrawableEditorRulesetWrapper drawableRulesetWrapper; protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; @@ -70,6 +77,8 @@ namespace osu.Game.Rulesets.Edit private FillFlowContainer togglesCollection; + private FillFlowContainer sampleBankTogglesCollection; + private IBindable hasTiming; private Bindable autoSeekOnPlacement; @@ -82,7 +91,7 @@ namespace osu.Game.Rulesets.Edit dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, OsuConfigManager config) + private void load(OsuConfigManager config) { autoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); @@ -109,6 +118,11 @@ namespace osu.Game.Rulesets.Edit PlayfieldContentContainer = new Container { Name = "Content", + Padding = new MarginPadding + { + Left = TOOLBOX_CONTRACTED_SIZE_LEFT, + Right = TOOLBOX_CONTRACTED_SIZE_RIGHT, + }, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { @@ -131,7 +145,7 @@ namespace osu.Game.Rulesets.Edit Colour = colourProvider.Background5, RelativeSizeAxes = Axes.Both, }, - new ExpandingToolboxContainer(60, 200) + LeftToolbox = new ExpandingToolboxContainer(TOOLBOX_CONTRACTED_SIZE_LEFT, 200) { Children = new Drawable[] { @@ -148,11 +162,43 @@ namespace osu.Game.Rulesets.Edit Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), }, + }, + new EditorToolboxGroup("bank (Shift-Q~R)") + { + Child = sampleBankTogglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, } } }, } }, + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + RightToolbox = new ExpandingToolboxContainer(TOOLBOX_CONTRACTED_SIZE_RIGHT, 250) + { + Child = new EditorToolboxGroup("inspector") + { + Child = new HitObjectInspector() + }, + } + } + } }; toolboxCollection.Items = CompositionTools @@ -163,6 +209,8 @@ namespace osu.Game.Rulesets.Edit TernaryStates = CreateTernaryButtons().ToArray(); togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); + sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Select(b => new DrawableTernaryButton(b))); + setSelectTool(); EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; @@ -215,7 +263,7 @@ namespace osu.Game.Rulesets.Edit /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.TernaryStates; + protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. @@ -240,7 +288,7 @@ namespace osu.Game.Rulesets.Edit protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.ShiftPressed) + if (e.ControlPressed || e.AltPressed || e.SuperPressed) return false; if (checkLeftToggleFromKey(e.Key, out int leftIndex)) @@ -257,7 +305,9 @@ namespace osu.Game.Rulesets.Edit if (checkRightToggleFromKey(e.Key, out int rightIndex)) { - var item = togglesCollection.ElementAtOrDefault(rightIndex); + var item = e.ShiftPressed + ? sampleBankTogglesCollection.ElementAtOrDefault(rightIndex) + : togglesCollection.ElementAtOrDefault(rightIndex); if (item is DrawableTernaryButton button) { @@ -394,7 +444,7 @@ namespace osu.Game.Rulesets.Edit var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); double? targetTime = null; - if (snapType.HasFlagFast(SnapType.Grids)) + if (snapType.HasFlagFast(SnapType.GlobalGrids)) { if (playfield is ScrollingPlayfield scrollingPlayfield) { @@ -421,6 +471,9 @@ namespace osu.Game.Rulesets.Edit [Cached] public abstract partial class HitObjectComposer : CompositeDrawable, IPositionSnapProvider { + public const float TOOLBOX_CONTRACTED_SIZE_LEFT = 60; + public const float TOOLBOX_CONTRACTED_SIZE_RIGHT = 130; + public readonly Ruleset Ruleset; protected HitObjectComposer(Ruleset ruleset) diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index f810f51027..717c026ded 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.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. -#nullable disable - 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.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; using osuTK; @@ -24,28 +25,44 @@ namespace osu.Game.Rulesets.Edit /// /// A blueprint which governs the creation of a new to actualisation. /// - public abstract partial class PlacementBlueprint : CompositeDrawable + public abstract partial class PlacementBlueprint : CompositeDrawable, IKeyBindingHandler { /// /// Whether the is currently mid-placement, but has not necessarily finished being placed. /// public PlacementState PlacementActive { get; private set; } + /// + /// Whether the sample bank should be taken from the previous hit object. + /// + public bool AutomaticBankAssignment { get; set; } + /// /// The that is being placed. /// public readonly HitObject HitObject; - [Resolved(canBeNull: true)] - protected EditorClock EditorClock { get; private set; } + [Resolved] + protected EditorClock EditorClock { get; private set; } = null!; [Resolved] - private EditorBeatmap beatmap { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; - private Bindable startTimeBindable; + private Bindable startTimeBindable = null!; + + private HitObject? getPreviousHitObject() => beatmap.HitObjects.TakeWhile(h => h.StartTime <= startTimeBindable.Value).LastOrDefault(); [Resolved] - private IPlacementHandler placementHandler { get; set; } + private IPlacementHandler placementHandler { get; set; } = null!; + + /// + /// Whether this blueprint is currently in a state that can be committed. + /// + /// + /// Override this with any preconditions that should be double-checked on committing. + /// If false is returned and a commit is attempted, the blueprint will be destroyed instead. + /// + protected virtual bool IsValidForPlacement => true; protected PlacementBlueprint(HitObject hitObject) { @@ -74,10 +91,6 @@ namespace osu.Game.Rulesets.Edit /// Whether this call is committing a value for HitObject.StartTime and continuing with further adjustments. protected void BeginPlacement(bool commitStart = false) { - var nearestSampleControlPoint = beatmap.HitObjects.LastOrDefault(h => h.GetEndTime() < HitObject.StartTime)?.SampleControlPoint?.DeepClone() as SampleControlPoint; - - HitObject.SampleControlPoint = nearestSampleControlPoint ?? new SampleControlPoint(); - placementHandler.BeginPlacement(HitObject); if (commitStart) PlacementActive = PlacementState.Active; @@ -87,7 +100,7 @@ namespace osu.Game.Rulesets.Edit /// Signals that the placement of has finished. /// This will destroy this , and add the HitObject.StartTime to the . /// - /// Whether the object should be committed. + /// Whether the object should be committed. Note that a commit may fail if is false. public void EndPlacement(bool commit) { switch (PlacementActive) @@ -101,10 +114,34 @@ namespace osu.Game.Rulesets.Edit break; } - placementHandler.EndPlacement(HitObject, commit); + placementHandler.EndPlacement(HitObject, IsValidForPlacement && commit); PlacementActive = PlacementState.Finished; } + public bool OnPressed(KeyBindingPressEvent e) + { + if (PlacementActive == PlacementState.Waiting) + return false; + + switch (e.Action) + { + case GlobalAction.Select: + EndPlacement(true); + return true; + + case GlobalAction.Back: + EndPlacement(false); + return true; + + default: + return false; + } + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + /// /// Updates the time and position of this based on the provided snap information. /// @@ -112,7 +149,20 @@ namespace osu.Game.Rulesets.Edit public virtual void UpdateTimeAndPosition(SnapResult result) { if (PlacementActive == PlacementState.Waiting) - HitObject.StartTime = result.Time ?? EditorClock?.CurrentTime ?? Time.Current; + { + HitObject.StartTime = result.Time ?? EditorClock.CurrentTime; + + if (HitObject is IHasComboInformation comboInformation) + comboInformation.UpdateComboInformation(getPreviousHitObject() as IHasComboInformation); + } + + if (AutomaticBankAssignment) + { + // Take the hitnormal sample of the last hit object + var lastHitNormal = getPreviousHitObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); + if (lastHitNormal != null) + HitObject.Samples[0] = lastHitNormal; + } } /// diff --git a/osu.Game/Rulesets/Edit/SnapType.cs b/osu.Game/Rulesets/Edit/SnapType.cs index 6eb46457c8..f5f9ab0437 100644 --- a/osu.Game/Rulesets/Edit/SnapType.cs +++ b/osu.Game/Rulesets/Edit/SnapType.cs @@ -11,8 +11,24 @@ namespace osu.Game.Rulesets.Edit public enum SnapType { None = 0, + + /// + /// Snapping to visible nearby objects. + /// NearbyObjects = 1 << 0, - Grids = 1 << 1, - All = NearbyObjects | Grids, + + /// + /// 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/Judgements/JudgementResult.cs b/osu.Game/Rulesets/Judgements/JudgementResult.cs index bf29919e34..34d1f1f6e9 100644 --- a/osu.Game/Rulesets/Judgements/JudgementResult.cs +++ b/osu.Game/Rulesets/Judgements/JudgementResult.cs @@ -64,6 +64,11 @@ namespace osu.Game.Rulesets.Judgements /// public int ComboAtJudgement { get; internal set; } + /// + /// The combo after this occurred. + /// + public int ComboAfterJudgement { get; internal set; } + /// /// The highest combo achieved prior to this occurring. /// diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 04d55bc5fe..f9812d6c00 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -10,8 +10,8 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Framework.Testing; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets.UI; using osu.Game.Utils; @@ -20,7 +20,6 @@ namespace osu.Game.Rulesets.Mods /// /// The base class for gameplay modifiers. /// - [ExcludeFromDynamicCompile] public abstract class Mod : IMod, IEquatable, IDeepCloneable { [JsonIgnore] @@ -113,21 +112,29 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual Type[] IncompatibleMods => Array.Empty(); - private IReadOnlyList? settingsBacking; + private IReadOnlyDictionary? settingsBacking; /// - /// A list of the all settings within this mod. + /// All settings within this mod. /// - internal IReadOnlyList Settings => + /// + /// The settings are returned in ascending key order as per . + /// The ordering is intentionally enforced manually, as ordering of is unspecified. + /// + internal IEnumerable SettingsBindables => SettingsMap.OrderBy(pair => pair.Key).Select(pair => pair.Value); + + /// + /// Provides mapping of names to s of all settings within this mod. + /// + internal IReadOnlyDictionary SettingsMap => settingsBacking ??= this.GetSettingsSourceProperties() - .Select(p => p.Item2.GetValue(this)) - .Cast() - .ToList(); + .Select(p => p.Item2) + .ToDictionary(property => property.Name.ToSnakeCase(), property => (IBindable)property.GetValue(this)!); /// /// Whether all settings in this mod are set to their default state. /// - protected virtual bool UsesDefaultConfiguration => Settings.All(s => s.IsDefault); + protected virtual bool UsesDefaultConfiguration => SettingsBindables.All(s => s.IsDefault); /// /// Creates a copy of this initialised to a default state. @@ -148,15 +155,53 @@ namespace osu.Game.Rulesets.Mods if (source.GetType() != GetType()) throw new ArgumentException($"Expected mod of type {GetType()}, got {source.GetType()}.", nameof(source)); - foreach (var (_, prop) in this.GetSettingsSourceProperties()) + foreach (var (_, property) in this.GetSettingsSourceProperties()) { - var targetBindable = (IBindable)prop.GetValue(this)!; - var sourceBindable = (IBindable)prop.GetValue(source)!; + var targetBindable = (IBindable)property.GetValue(this)!; + var sourceBindable = (IBindable)property.GetValue(source)!; CopyAdjustedSetting(targetBindable, sourceBindable); } } + /// + /// This method copies the values of all settings from that share the same names with this mod instance. + /// The most frequent use of this is when switching rulesets, in order to preserve values of common settings during the switch. + /// + /// + /// The values are copied directly, without adjusting for possibly different allowed ranges of values. + /// If the value of a setting is not valid for this instance due to not falling inside of the allowed range, it will be clamped accordingly. + /// + /// The mod to extract settings from. + public void CopyCommonSettingsFrom(Mod source) + { + if (source.UsesDefaultConfiguration) + return; + + foreach (var (name, targetSetting) in SettingsMap) + { + if (!source.SettingsMap.TryGetValue(name, out IBindable? sourceSetting)) + continue; + + if (sourceSetting.IsDefault) + continue; + + var targetBindableType = targetSetting.GetType(); + var sourceBindableType = sourceSetting.GetType(); + + // if either the target is assignable to the source or the source is assignable to the target, + // then we presume that the data types contained in both bindables are compatible and we can proceed with the copy. + // this handles cases like `Bindable` and `BindableInt`. + if (!targetBindableType.IsAssignableFrom(sourceBindableType) && !sourceBindableType.IsAssignableFrom(targetBindableType)) + continue; + + // TODO: special case for handling number types + + PropertyInfo property = targetSetting.GetType().GetProperty(nameof(Bindable.Value))!; + property.SetValue(targetSetting, property.GetValue(sourceSetting)); + } + } + /// /// When creating copies or clones of a Mod, this method will be called /// to copy explicitly adjusted user settings from . @@ -191,7 +236,7 @@ namespace osu.Game.Rulesets.Mods if (ReferenceEquals(this, other)) return true; return GetType() == other.GetType() && - Settings.SequenceEqual(other.Settings, ModSettingsEqualityComparer.Default); + SettingsBindables.SequenceEqual(other.SettingsBindables, ModSettingsEqualityComparer.Default); } public override int GetHashCode() @@ -200,7 +245,7 @@ namespace osu.Game.Rulesets.Mods hashCode.Add(GetType()); - foreach (var setting in Settings) + foreach (var setting in SettingsBindables) hashCode.Add(setting.GetUnderlyingSettingValue()); return hashCode.ToHashCode(); diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index d4223a80c2..0072c21053 100644 --- a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation.HUD; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Judgements; @@ -42,30 +43,44 @@ namespace osu.Game.Rulesets.Mods Value = 0.9, }; - private ScoreProcessor scoreProcessor = null!; + [SettingSource("Accuracy mode", "The mode of accuracy that will trigger failure.")] + public Bindable AccuracyJudgeMode { get; } = new Bindable(); - public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) => this.scoreProcessor = scoreProcessor; + private readonly Bindable currentAccuracy = new Bindable(); + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + switch (AccuracyJudgeMode.Value) + { + case AccuracyMode.Standard: + currentAccuracy.BindTo(scoreProcessor.Accuracy); + break; + + case AccuracyMode.MaximumAchievable: + currentAccuracy.BindTo(scoreProcessor.MaximumAccuracy); + break; + } + + currentAccuracy.BindValueChanged(s => + { + if (s.NewValue < MinimumAccuracy.Value) + { + TriggerFailure(); + } + }); + } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; - protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => false; + + public enum AccuracyMode { - if (!result.Type.AffectsAccuracy()) - return false; + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeMax))] + MaximumAchievable, - return getAccuracyWithImminentResultAdded(result) < MinimumAccuracy.Value; - } - - private double getAccuracyWithImminentResultAdded(JudgementResult result) - { - var score = new ScoreInfo { Ruleset = scoreProcessor.Ruleset.RulesetInfo }; - - // This is super ugly, but if we don't do it this way we will not have the most recent result added to the accuracy value. - // Hopefully we can improve this in the future. - scoreProcessor.PopulateScore(score); - score.Statistics[result.Type]++; - - return scoreProcessor.ComputeAccuracy(score); + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeStandard))] + Standard, } } } diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 9e4469bf25..733610c040 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -24,5 +24,22 @@ namespace osu.Game.Rulesets.Mods MaxValue = 2, Precision = 0.01, }; + + public override double ScoreMultiplier + { + get + { + // Round to the nearest multiple of 0.1. + double value = (int)(SpeedChange.Value * 10) / 10.0; + + // Offset back to 0. + value -= 1; + + // Each 0.1 multiple changes score multiplier by 0.02. + value /= 5; + + return 1 + value; + } + } } } diff --git a/osu.Game/Rulesets/Mods/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs index 4425ece513..97789b7f5a 100644 --- a/osu.Game/Rulesets/Mods/ModFailCondition.cs +++ b/osu.Game/Rulesets/Mods/ModFailCondition.cs @@ -20,11 +20,31 @@ namespace osu.Game.Rulesets.Mods public virtual bool RestartOnFail => Restart.Value; + private Action? triggerFailureDelegate; + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) { + triggerFailureDelegate = healthProcessor.TriggerFailure; healthProcessor.FailConditions += FailCondition; } + /// + /// Immediately triggers a failure on the loaded . + /// + protected void TriggerFailure() => triggerFailureDelegate?.Invoke(); + + /// + /// Determines whether should trigger a failure. Called every time a + /// judgement is applied to . + /// + /// The loaded . + /// The latest . + /// Whether the fail condition has been met. + /// + /// This method should only be used to trigger failures based on . + /// Using outside values to evaluate failure may introduce event ordering discrepancies, use + /// an with instead. + /// protected abstract bool FailCondition(HealthProcessor healthProcessor, JudgementResult result); } } diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index a7889cb98d..215fc877dc 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -82,8 +82,11 @@ namespace osu.Game.Rulesets.Mods flashlight.RelativeSizeAxes = Axes.Both; flashlight.Colour = Color4.Black; + // Flashlight mods should always draw above any other mod adding overlays. + flashlight.Depth = float.MinValue; flashlight.Combo.BindTo(Combo); + drawableRuleset.Overlays.Add(flashlight); } @@ -273,7 +276,7 @@ namespace osu.Game.Rulesets.Mods }; shader.Bind(); - shader.BindUniformBlock("m_FlashlightParameters", flashlightParametersBuffer); + shader.BindUniformBlock(@"m_FlashlightParameters", flashlightParametersBuffer); renderer.DrawQuad(renderer.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour, vertexAction: addAction); diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 7d858dca6f..06c7750035 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -24,5 +24,19 @@ namespace osu.Game.Rulesets.Mods MaxValue = 0.99, Precision = 0.01, }; + + public override double ScoreMultiplier + { + get + { + // Round to the nearest multiple of 0.1. + double value = (int)(SpeedChange.Value * 10) / 10.0; + + // Offset back to 0. + value -= 1; + + return 1 + value; + } + } } } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 0c50f8341a..07c0d1f8a1 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -30,7 +30,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables { [Cached(typeof(DrawableHitObject))] - public abstract partial class DrawableHitObject : PoolableDrawableWithLifetime + public abstract partial class DrawableHitObject : PoolableDrawableWithLifetime, IAnimationTimeReference { /// /// Invoked after this 's applied has had its defaults applied. @@ -239,6 +239,7 @@ namespace osu.Game.Rulesets.Objects.Drawables OnNestedDrawableCreated?.Invoke(drawableNested); drawableNested.OnNewResult += onNewResult; + drawableNested.OnRevertResult += onNestedRevertResult; drawableNested.ApplyCustomUpdateState += onApplyCustomUpdateState; // This is only necessary for non-pooled DHOs. For pooled DHOs, this is handled inside GetPooledDrawableRepresentation(). @@ -312,6 +313,7 @@ namespace osu.Game.Rulesets.Objects.Drawables foreach (var obj in nestedHitObjects) { obj.OnNewResult -= onNewResult; + obj.OnRevertResult -= onNestedRevertResult; obj.ApplyCustomUpdateState -= onApplyCustomUpdateState; } @@ -355,13 +357,7 @@ namespace osu.Game.Rulesets.Objects.Drawables if (samples.Length <= 0) return; - if (HitObject.SampleControlPoint == null) - { - throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}." - + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); - } - - Samples.Samples = samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + Samples.Samples = samples.Cast().ToArray(); } private void onSamplesChanged(object sender, NotifyCollectionChangedEventArgs e) => LoadSamples(); @@ -376,6 +372,8 @@ namespace osu.Game.Rulesets.Objects.Drawables OnRevertResult?.Invoke(this, Result); } + private void onNestedRevertResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnRevertResult?.Invoke(drawableHitObject, result); + private void onApplyCustomUpdateState(DrawableHitObject drawableHitObject, ArmedState state) => ApplyCustomUpdateState?.Invoke(drawableHitObject, state); private void onDefaultsApplied(HitObject hitObject) @@ -427,11 +425,13 @@ namespace osu.Game.Rulesets.Objects.Drawables LifetimeEnd = double.MaxValue; - double transformTime = HitObject.StartTime - InitialLifetimeOffset; - clearExistingStateTransforms(); - using (BeginAbsoluteSequence(transformTime)) + double initialTransformsTime = HitObject.StartTime - InitialLifetimeOffset; + + AnimationStartTime.Value = initialTransformsTime; + + using (BeginAbsoluteSequence(initialTransformsTime)) UpdateInitialTransforms(); using (BeginAbsoluteSequence(StateUpdateTime)) @@ -723,6 +723,8 @@ namespace osu.Game.Rulesets.Objects.Drawables if (CurrentSkin != null) CurrentSkin.SourceChanged -= skinSourceChanged; } + + public Bindable AnimationStartTime { get; } = new BindableDouble(); } public abstract partial class DrawableHitObject : DrawableHitObject diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 25f538d211..ed3d3a6eb2 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -16,7 +16,6 @@ using osu.Framework.Lists; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; @@ -77,8 +76,11 @@ namespace osu.Game.Rulesets.Objects /// public virtual IList AuxiliarySamples => ImmutableList.Empty; - public SampleControlPoint SampleControlPoint = SampleControlPoint.DEFAULT; - public DifficultyControlPoint DifficultyControlPoint = DifficultyControlPoint.DEFAULT; + /// + /// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it. + /// DO NOT USE THIS UNLESS 100% SURE. + /// + public double? LegacyBpmMultiplier { get; set; } /// /// Whether this is in Kiai time. @@ -105,25 +107,8 @@ namespace osu.Game.Rulesets.Objects /// The cancellation token. public void ApplyDefaults(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty, CancellationToken cancellationToken = default) { - var legacyInfo = controlPointInfo as LegacyControlPointInfo; - - if (legacyInfo != null) - DifficultyControlPoint = (DifficultyControlPoint)legacyInfo.DifficultyPointAt(StartTime).DeepClone(); - else if (ReferenceEquals(DifficultyControlPoint, DifficultyControlPoint.DEFAULT)) - DifficultyControlPoint = new DifficultyControlPoint(); - - DifficultyControlPoint.Time = StartTime; - ApplyDefaultsToSelf(controlPointInfo, difficulty); - // This is done here after ApplyDefaultsToSelf as we may require custom defaults to be applied to have an accurate end time. - if (legacyInfo != null) - SampleControlPoint = (SampleControlPoint)legacyInfo.SamplePointAt(this.GetEndTime() + control_point_leniency).DeepClone(); - else if (ReferenceEquals(SampleControlPoint, SampleControlPoint.DEFAULT)) - SampleControlPoint = new SampleControlPoint(); - - SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; - nestedHitObjects.Clear(); CreateNestedHitObjects(cancellationToken); @@ -164,9 +149,6 @@ namespace osu.Game.Rulesets.Objects foreach (var nested in nestedHitObjects) nested.StartTime += offset; - - DifficultyControlPoint.Time = time.NewValue; - SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; } } @@ -222,6 +204,23 @@ namespace osu.Game.Rulesets.Objects return slidingSamples; } + + /// + /// Create a based on the sample settings of the first sample in . + /// If no sample is available, sane default settings will be used instead. + /// + /// + /// In the case an existing sample exists, all settings apart from the sample name will be inherited. This includes volume, bank and suffix. + /// + /// The name of the sample. + /// A populated . + public HitSampleInfo CreateHitSampleInfo(string sampleName = HitSampleInfo.HIT_NORMAL) + { + if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingSample) + return existingSample.With(newName: sampleName); + + return new HitSampleInfo(sampleName); + } } public static class HitObjectExtensions diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 68ca6bc506..d9738ecd0a 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -14,6 +14,7 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Skinning; using osu.Game.Utils; @@ -92,7 +93,7 @@ namespace osu.Game.Rulesets.Objects.Legacy } if (split.Length > 10) - readCustomSampleBanks(split[10], bankInfo); + readCustomSampleBanks(split[10], bankInfo, true); // One node for each repeat + the start and end nodes int nodes = repeatCount + 2; @@ -182,7 +183,7 @@ namespace osu.Game.Rulesets.Objects.Legacy return result; } - private void readCustomSampleBanks(string str, SampleBankInfo bankInfo) + private void readCustomSampleBanks(string str, SampleBankInfo bankInfo, bool banksOnly = false) { if (string.IsNullOrEmpty(str)) return; @@ -202,6 +203,8 @@ namespace osu.Game.Rulesets.Objects.Legacy bankInfo.BankForNormal = stringBank; bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; + if (banksOnly) return; + if (split.Length > 2) bankInfo.CustomSampleBank = Parsing.ParseInt(split[2]); @@ -444,9 +447,9 @@ namespace osu.Game.Rulesets.Objects.Legacy if (string.IsNullOrEmpty(bankInfo.Filename)) { soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, bankInfo.CustomSampleBank, - // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. - // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds - type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal))); + // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. + // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds + type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal))); } else { @@ -477,12 +480,14 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The bank identifier to use for the base ("hitnormal") sample. /// Transferred to when appropriate. /// + [CanBeNull] public string BankForNormal; /// /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). /// Transferred to when appropriate. /// + [CanBeNull] public string BankForAdditions; /// @@ -516,17 +521,24 @@ namespace osu.Game.Rulesets.Objects.Legacy /// public readonly bool IsLayered; + /// + /// Whether a bank was specified locally to the relevant hitobject. + /// If false, a bank will be retrieved from the closest control point. + /// + public bool BankSpecified; + public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, int customSampleBank = 0, bool isLayered = false) - : base(name, bank, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) + : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) { 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) + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) => With(newName, newBank, newVolume); - public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); @@ -561,7 +573,7 @@ namespace osu.Game.Rulesets.Objects.Legacy Path.ChangeExtension(Filename, null) }; - public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) => new FileHitSampleInfo(Filename, newVolume.GetOr(Volume)); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index bd2713a7d1..7ddd372dc9 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -6,13 +6,14 @@ using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Objects.Legacy { - internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasLegacyLastTickOffset + internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasLegacyLastTickOffset, IHasSliderVelocity { /// /// Scoring distance with a speed-adjusted beat length of 1 second. @@ -40,13 +41,21 @@ namespace osu.Game.Rulesets.Objects.Legacy public double Velocity = 1; + public BindableNumber SliderVelocityBindable { get; } = new BindableDouble(1); + + public double SliderVelocity + { + get => SliderVelocityBindable.Value; + set => SliderVelocityBindable.Value = value; + } + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); - double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; + double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; } diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index b45ea989f3..d34e71021f 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.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.Bindables; using osu.Game.Skinning; using osuTK.Graphics; @@ -65,5 +63,26 @@ namespace osu.Game.Rulesets.Objects.Types { return skin.GetConfig(new SkinComboColourLookup(comboIndex, combo))?.Value ?? Color4.White; } + + /// + /// Given the previous object in the beatmap, update relevant combo information. + /// + /// 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; + + if (NewCombo || lastObj == null) + { + IndexInCurrentCombo = 0; + ComboIndex++; + ComboIndexWithOffsets += ComboOffset + 1; + + if (lastObj != null) + lastObj.LastInCombo = true; + } + } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasGenerateTicks.cs b/osu.Game/Rulesets/Objects/Types/IHasGenerateTicks.cs new file mode 100644 index 0000000000..3ac8b8a086 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasGenerateTicks.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Objects.Types +{ + /// + /// A type of which explicitly specifies whether it should generate ticks. + /// + public interface IHasGenerateTicks + { + /// + /// 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; } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasSliderVelocity.cs b/osu.Game/Rulesets/Objects/Types/IHasSliderVelocity.cs new file mode 100644 index 0000000000..80fd8dd8dc --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasSliderVelocity.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; + +namespace osu.Game.Rulesets.Objects.Types +{ + /// + /// A HitObject that has a slider velocity multiplier. + /// + public interface IHasSliderVelocity + { + /// + /// The slider velocity multiplier. + /// + double SliderVelocity { get; set; } + + BindableNumber SliderVelocityBindable { get; } + } +} diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index fcf7a78090..490ec1475c 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.IO.Stores; using osu.Framework.Localisation; -using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; @@ -34,7 +33,6 @@ using osu.Game.Users; namespace osu.Game.Rulesets { - [ExcludeFromDynamicCompile] public abstract class Ruleset { public RulesetInfo RulesetInfo { get; } @@ -323,8 +321,8 @@ namespace osu.Game.Rulesets /// /// The to create the statistics for. The score is guaranteed to have populated. /// The , converted for this with all relevant s applied. - /// The s to display. Each may contain 0 or more . - public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); + /// The s to display. + public virtual StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); /// /// Get all valid s for this ruleset. diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index 6a4e0f0b48..56f073f74c 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -3,13 +3,11 @@ using System; using JetBrains.Annotations; -using osu.Framework.Testing; using osu.Game.Rulesets.Difficulty; using Realms; namespace osu.Game.Rulesets { - [ExcludeFromDynamicCompile] [MapTo("Ruleset")] public class RulesetInfo : RealmObject, IEquatable, IComparable, IRulesetInfo { diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index b70ddd5e24..3e0b6433c2 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -31,6 +31,15 @@ namespace osu.Game.Rulesets.Scoring /// public bool HasFailed { get; private set; } + /// + /// Immediately triggers a failure for this HealthProcessor. + /// + public void TriggerFailure() + { + if (Failed?.Invoke() != false) + HasFailed = true; + } + protected override void ApplyResultInternal(JudgementResult result) { result.HealthAtJudgement = Health.Value; @@ -42,10 +51,7 @@ namespace osu.Game.Rulesets.Scoring Health.Value += GetHealthIncreaseFor(result); if (meetsAnyFailCondition(result)) - { - if (Failed?.Invoke() != false) - HasFailed = true; - } + TriggerFailure(); } protected override void RevertResultInternal(JudgementResult result) diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 83ed98768c..0013a9f20d 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -150,7 +150,7 @@ namespace osu.Game.Rulesets.Scoring => AffectsCombo(result) && !IsHit(result); /// - /// Whether a increases/breaks the combo, and affects the combo portion of the score. + /// Whether a increases or breaks the combo. /// public static bool AffectsCombo(this HitResult result) { diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 96f6922224..ac17de32d8 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -4,11 +4,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.Contracts; using System.Linq; +using MessagePack; using osu.Framework.Bindables; using osu.Framework.Localisation; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Localisation; @@ -22,6 +21,8 @@ namespace osu.Game.Rulesets.Scoring { public partial class ScoreProcessor : JudgementProcessor { + public const double MAX_SCORE = 1000000; + private const double accuracy_cutoff_x = 1; private const double accuracy_cutoff_s = 0.95; private const double accuracy_cutoff_a = 0.9; @@ -29,8 +30,6 @@ namespace osu.Game.Rulesets.Scoring private const double accuracy_cutoff_c = 0.7; private const double accuracy_cutoff_d = 0; - private const double max_score = 1000000; - /// /// Invoked when this was reset from a replay frame. /// @@ -78,39 +77,72 @@ namespace osu.Game.Rulesets.Scoring /// public readonly BindableInt HighestCombo = new BindableInt(); - /// - /// The used to calculate scores. - /// - public readonly Bindable Mode = new Bindable(); - /// /// The s collected during gameplay thus far. /// Intended for use with various statistics displays. /// public IReadOnlyList HitEvents => hitEvents; - /// - /// The default portion of awarded for hitting s accurately. Defaults to 30%. - /// - protected virtual double DefaultAccuracyPortion => 0.3; - - /// - /// The default portion of awarded for achieving a high combo. Default to 70%. - /// - protected virtual double DefaultComboPortion => 0.7; - - /// - /// An arbitrary multiplier to scale scores in the scoring mode. - /// - protected virtual double ClassicScoreMultiplier => 36; - /// /// The ruleset this score processor is valid for. /// public readonly Ruleset Ruleset; - private readonly double accuracyPortion; - private readonly double comboPortion; + /// + /// The maximum achievable total score. + /// + public long MaximumTotalScore { get; private set; } + + /// + /// The maximum sum of accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// + private double currentMaximumBaseScore; + + /// + /// The sum of all accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// + private double currentBaseScore; + + /// + /// The maximum sum of all accuracy-affecting judgements in the beatmap. + /// + private double maximumBaseScore; + + /// + /// The count of all accuracy-affecting judgements in the beatmap. + /// + private int maximumAccuracyJudgementCount; + + /// + /// The count of accuracy-affecting judgements at the current point in time. + /// + private int currentAccuracyJudgementCount; + + /// + /// The maximum combo score in the beatmap. + /// + private double maximumComboPortion; + + /// + /// The combo score at the current point in time. + /// + private double currentComboPortion; + + /// + /// The bonus score at the current point in time. + /// + private double currentBonusPortion; + + /// + /// The total score multiplier. + /// + private double scoreMultiplier = 1; public Dictionary MaximumStatistics { @@ -123,27 +155,6 @@ namespace osu.Game.Rulesets.Scoring } } - private ScoringValues maximumScoringValues; - - /// - /// Scoring values for the current play assuming all perfect hits. - /// - /// - /// This is only used to determine the accuracy with respect to the current point in time for an ongoing play session. - /// - private ScoringValues currentMaximumScoringValues; - - /// - /// Scoring values for the current play. - /// - private ScoringValues currentScoringValues; - - /// - /// The maximum of a basic (non-tick and non-bonus) hitobject. - /// Only populated via or . - /// - private HitResult? maxBasicResult; - private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); @@ -152,18 +163,10 @@ namespace osu.Game.Rulesets.Scoring private readonly List hitEvents = new List(); private HitObject? lastHitObject; - private double scoreMultiplier = 1; - public ScoreProcessor(Ruleset ruleset) { Ruleset = ruleset; - accuracyPortion = DefaultAccuracyPortion; - comboPortion = DefaultComboPortion; - - if (!Precision.AlmostEquals(1.0, accuracyPortion + comboPortion)) - throw new InvalidOperationException($"{nameof(DefaultAccuracyPortion)} + {nameof(DefaultComboPortion)} must equal 1."); - Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => { @@ -172,7 +175,6 @@ namespace osu.Game.Rulesets.Scoring Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; - Mode.ValueChanged += _ => updateScore(); Mods.ValueChanged += mods => { scoreMultiplier = 1; @@ -200,10 +202,6 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; - // Always update the maximum scoring values. - applyResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); - currentMaximumScoringValues.MaxCombo += result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; - if (!result.Type.IsScorable()) return; @@ -212,8 +210,21 @@ namespace osu.Game.Rulesets.Scoring else if (result.Type.BreaksCombo()) Combo.Value = 0; - applyResult(result.Type, ref currentScoringValues); - currentScoringValues.MaxCombo = HighestCombo.Value; + result.ComboAfterJudgement = Combo.Value; + + if (result.Type.AffectsAccuracy()) + { + currentMaximumBaseScore += Judgement.ToNumericResult(result.Judgement.MaxResult); + currentBaseScore += Judgement.ToNumericResult(result.Type); + currentAccuracyJudgementCount++; + } + + if (result.Type.IsBonus()) + currentBonusPortion += GetBonusScoreChange(result); + else + currentComboPortion += GetComboScoreChange(result); + + ApplyScoreChange(result); hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; @@ -221,20 +232,6 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } - private static void applyResult(HitResult result, ref ScoringValues scoringValues) - { - if (!result.IsScorable()) - return; - - if (result.IsBonus()) - scoringValues.BonusScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; - else - scoringValues.BaseScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; - - if (result.IsBasic()) - scoringValues.CountBasicHitObjects++; - } - /// /// Creates the that describes a . /// @@ -253,15 +250,22 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; - // Always update the maximum scoring values. - revertResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); - currentMaximumScoringValues.MaxCombo -= result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; - if (!result.Type.IsScorable()) return; - revertResult(result.Type, ref currentScoringValues); - currentScoringValues.MaxCombo = HighestCombo.Value; + if (result.Type.AffectsAccuracy()) + { + currentMaximumBaseScore -= Judgement.ToNumericResult(result.Judgement.MaxResult); + currentBaseScore -= Judgement.ToNumericResult(result.Type); + currentAccuracyJudgementCount--; + } + + if (result.Type.IsBonus()) + currentBonusPortion -= GetBonusScoreChange(result); + else + currentComboPortion -= GetComboScoreChange(result); + + RemoveScoreChange(result); Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; @@ -270,110 +274,35 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } - private static void revertResult(HitResult result, ref ScoringValues scoringValues) + protected virtual double GetBonusScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type); + + protected virtual double GetComboScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type) * (1 + result.ComboAfterJudgement / 10d); + + protected virtual void ApplyScoreChange(JudgementResult result) { - if (!result.IsScorable()) - return; + } - if (result.IsBonus()) - scoringValues.BonusScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; - else - scoringValues.BaseScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; - - if (result.IsBasic()) - scoringValues.CountBasicHitObjects--; + protected virtual void RemoveScoreChange(JudgementResult result) + { } private void updateScore() { - Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? (double)currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; - MinimumAccuracy.Value = maximumScoringValues.BaseScore > 0 ? (double)currentScoringValues.BaseScore / maximumScoringValues.BaseScore : 0; - MaximumAccuracy.Value = maximumScoringValues.BaseScore > 0 - ? (double)(currentScoringValues.BaseScore + (maximumScoringValues.BaseScore - currentMaximumScoringValues.BaseScore)) / maximumScoringValues.BaseScore - : 1; - TotalScore.Value = computeScore(Mode.Value, currentScoringValues, maximumScoringValues); + Accuracy.Value = currentMaximumBaseScore > 0 ? currentBaseScore / currentMaximumBaseScore : 1; + MinimumAccuracy.Value = maximumBaseScore > 0 ? currentBaseScore / maximumBaseScore : 0; + MaximumAccuracy.Value = maximumBaseScore > 0 ? (currentBaseScore + (maximumBaseScore - currentMaximumBaseScore)) / maximumBaseScore : 1; + + double comboProgress = maximumComboPortion > 0 ? currentComboPortion / maximumComboPortion : 1; + double accuracyProcess = maximumAccuracyJudgementCount > 0 ? (double)currentAccuracyJudgementCount / maximumAccuracyJudgementCount : 1; + + TotalScore.Value = (long)Math.Round(ComputeTotalScore(comboProgress, accuracyProcess, currentBonusPortion) * scoreMultiplier); } - /// - /// Computes the accuracy of a given . - /// - /// The to compute the total score of. - /// The score's accuracy. - [Pure] - public double ComputeAccuracy(ScoreInfo scoreInfo) + protected virtual double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { - if (!Ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) - throw new ArgumentException($"Unexpected score ruleset. Expected \"{Ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - - // We only extract scoring values from the score's statistics. This is because accuracy is always relative to the point of pass or fail rather than relative to the whole beatmap. - extractScoringValues(scoreInfo.Statistics, out var current, out var maximum); - - return maximum.BaseScore > 0 ? (double)current.BaseScore / maximum.BaseScore : 1; - } - - /// - /// Computes the total score of a given . - /// - /// - /// Does not require to have been called before use. - /// - /// The to represent the score as. - /// The to compute the total score of. - /// The total score in the given . - [Pure] - public long ComputeScore(ScoringMode mode, ScoreInfo scoreInfo) - { - if (!Ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) - throw new ArgumentException($"Unexpected score ruleset. Expected \"{Ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - - extractScoringValues(scoreInfo, out var current, out var maximum); - - return computeScore(mode, current, maximum); - } - - /// - /// Computes the total score from scoring values. - /// - /// The to represent the score as. - /// The current scoring values. - /// The maximum scoring values. - /// The total score computed from the given scoring values. - [Pure] - private long computeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) - { - double accuracyRatio = maximum.BaseScore > 0 ? (double)current.BaseScore / maximum.BaseScore : 1; - double comboRatio = maximum.MaxCombo > 0 ? (double)current.MaxCombo / maximum.MaxCombo : 1; - return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); - } - - /// - /// Computes the total score from individual scoring components. - /// - /// The to represent the score as. - /// The accuracy percentage achieved by the player. - /// The portion of the max combo achieved by the player. - /// The total bonus score. - /// The total number of basic (non-tick and non-bonus) hitobjects in the beatmap. - /// The total score computed from the given scoring component ratios. - [Pure] - public long ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, long bonusScore, int totalBasicHitObjects) - { - double accuracyScore = accuracyPortion * accuracyRatio; - double comboScore = comboPortion * comboRatio; - double rawScore = (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; - - switch (mode) - { - default: - case ScoringMode.Standardised: - return (long)Math.Round(rawScore); - - case ScoringMode.Classic: - // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. - // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. - double scaledRawScore = rawScore / max_score; - return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier); - } + return 700000 * comboProgress + + 300000 * Math.Pow(Accuracy.Value, 10) * accuracyProgress + + bonusPortion; } /// @@ -389,16 +318,24 @@ namespace osu.Game.Rulesets.Scoring if (storeResults) { - maximumScoringValues = currentScoringValues; + maximumBaseScore = currentBaseScore; + + maximumComboPortion = currentComboPortion; + maximumAccuracyJudgementCount = currentAccuracyJudgementCount; maximumResultCounts.Clear(); maximumResultCounts.AddRange(scoreResultCounts); + + MaximumTotalScore = TotalScore.Value; } scoreResultCounts.Clear(); - currentScoringValues = default; - currentMaximumScoringValues = default; + currentBaseScore = 0; + currentMaximumBaseScore = 0; + currentAccuracyJudgementCount = 0; + currentComboPortion = 0; + currentBonusPortion = 0; TotalScore.Value = 0; Accuracy.Value = 1; @@ -428,7 +365,7 @@ namespace osu.Game.Rulesets.Scoring score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. - score.TotalScore = ComputeScore(ScoringMode.Standardised, score); + score.TotalScore = TotalScore.Value; } /// @@ -452,126 +389,36 @@ namespace osu.Game.Rulesets.Scoring if (frame.Header == null) return; - extractScoringValues(frame.Header.Statistics, out var current, out var maximum); - currentScoringValues.BaseScore = current.BaseScore; - currentScoringValues.MaxCombo = frame.Header.MaxCombo; - currentMaximumScoringValues.BaseScore = maximum.BaseScore; - currentMaximumScoringValues.MaxCombo = maximum.MaxCombo; - Combo.Value = frame.Header.Combo; HighestCombo.Value = frame.Header.MaxCombo; + TotalScore.Value = frame.Header.TotalScore; scoreResultCounts.Clear(); scoreResultCounts.AddRange(frame.Header.Statistics); + SetScoreProcessorStatistics(frame.Header.ScoreProcessorStatistics); + updateScore(); OnResetFromReplayFrame?.Invoke(); } - #region ScoringValue extraction - - /// - /// Applies a best-effort extraction of hit statistics into . - /// - /// - /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: - /// - /// The maximum will always be 0. - /// The current and maximum will always be the same value. - /// - /// Consumers are expected to more accurately fill in the above values through external means. - /// - /// Ensure to fill in the maximum for use in - /// . - /// - /// - /// The score to extract scoring values from. - /// The "current" scoring values, representing the hit statistics as they appear. - /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. - [Pure] - private void extractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum) + public ScoreProcessorStatistics GetScoreProcessorStatistics() => new ScoreProcessorStatistics { - extractScoringValues(scoreInfo.Statistics, out current, out maximum); - current.MaxCombo = scoreInfo.MaxCombo; + MaximumBaseScore = currentMaximumBaseScore, + BaseScore = currentBaseScore, + AccuracyJudgementCount = currentAccuracyJudgementCount, + ComboPortion = currentComboPortion, + BonusPortion = currentBonusPortion + }; - if (scoreInfo.MaximumStatistics.Count > 0) - extractScoringValues(scoreInfo.MaximumStatistics, out _, out maximum); - } - - /// - /// Applies a best-effort extraction of hit statistics into . - /// - /// - /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: - /// - /// The current will always be 0. - /// The maximum will always be 0. - /// The current and maximum will always be the same value. - /// - /// Consumers are expected to more accurately fill in the above values (especially the current ) via external means (e.g. ). - /// - /// The hit statistics to extract scoring values from. - /// The "current" scoring values, representing the hit statistics as they appear. - /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. - [Pure] - private void extractScoringValues(IReadOnlyDictionary statistics, out ScoringValues current, out ScoringValues maximum) + public void SetScoreProcessorStatistics(ScoreProcessorStatistics statistics) { - current = default; - maximum = default; - - foreach ((HitResult result, int count) in statistics) - { - if (!result.IsScorable()) - continue; - - if (result.IsBonus()) - current.BonusScore += count * Judgement.ToNumericResult(result); - - if (result.AffectsAccuracy()) - { - // The maximum result of this judgement if it wasn't a miss. - // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). - HitResult maxResult; - - switch (result) - { - case HitResult.LargeTickHit: - case HitResult.LargeTickMiss: - maxResult = HitResult.LargeTickHit; - break; - - case HitResult.SmallTickHit: - case HitResult.SmallTickMiss: - maxResult = HitResult.SmallTickHit; - break; - - default: - maxResult = maxBasicResult ??= Ruleset.GetHitResults().MaxBy(kvp => Judgement.ToNumericResult(kvp.result)).result; - break; - } - - current.BaseScore += count * Judgement.ToNumericResult(result); - maximum.BaseScore += count * Judgement.ToNumericResult(maxResult); - } - - if (result.AffectsCombo()) - maximum.MaxCombo += count; - - if (result.IsBasic()) - { - current.CountBasicHitObjects += count; - maximum.CountBasicHitObjects += count; - } - } - } - - #endregion - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - hitEvents.Clear(); + currentMaximumBaseScore = statistics.MaximumBaseScore; + currentBaseScore = statistics.BaseScore; + currentAccuracyJudgementCount = statistics.AccuracyJudgementCount; + currentComboPortion = statistics.ComboPortion; + currentBonusPortion = statistics.BonusPortion; } #region Static helper methods @@ -630,30 +477,10 @@ namespace osu.Game.Rulesets.Scoring #endregion - /// - /// Stores the required scoring data that fulfils the minimum requirements for a to calculate score. - /// - private struct ScoringValues + protected override void Dispose(bool isDisposing) { - /// - /// The sum of all "basic" scoring values. See: and . - /// - public long BaseScore; - - /// - /// The sum of all "bonus" scoring values. See: and . - /// - public long BonusScore; - - /// - /// The highest achieved combo. - /// - public int MaxCombo; - - /// - /// The count of "basic" s. See: . - /// - public int CountBasicHitObjects; + base.Dispose(isDisposing); + hitEvents.Clear(); } } @@ -665,4 +492,46 @@ namespace osu.Game.Rulesets.Scoring [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.ClassicScoreDisplay))] Classic } + + [Serializable] + [MessagePackObject] + public class ScoreProcessorStatistics + { + /// + /// The sum of all accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// See: and . + /// + [Key(0)] + public double BaseScore { get; set; } + + /// + /// The maximum sum of accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// + [Key(1)] + public double MaximumBaseScore { get; set; } + + /// + /// The count of accuracy-affecting judgements at the current point in time. + /// + [Key(2)] + public int AccuracyJudgementCount { get; set; } + + /// + /// The combo score at the current point in time. + /// + [Key(3)] + public double ComboPortion { get; set; } + + /// + /// The bonus score at the current point in time. + /// + [Key(4)] + public double BonusPortion { get; set; } + } } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 64fe9c8a86..4f22c0c617 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -30,6 +30,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.ClicksPerSecond; using osuTK; diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 96b02ee4dc..e34289c968 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -25,21 +25,28 @@ namespace osu.Game.Rulesets.UI /// /// The texture store to be used for the ruleset. /// + /// + /// Reads textures from the "Textures" folder in ruleset resources. + /// If not available locally, lookups will fallback to the global texture store. + /// public TextureStore TextureStore { get; } /// /// The sample store to be used for the ruleset. /// /// - /// This is the local sample store pointing to the ruleset sample resources, - /// the cached sample store () retrieves from - /// this store and falls back to the parent store if this store doesn't have the requested sample. + /// Reads samples from the "Samples" folder in ruleset resources. + /// If not available locally, lookups will fallback to the global sample store. /// public ISampleStore SampleStore { get; } /// /// The shader manager to be used for the ruleset. /// + /// + /// Reads shaders from the "Shaders" folder in ruleset resources. + /// If not available locally, lookups will fallback to the global shader manager. + /// public ShaderManager ShaderManager { get; } /// @@ -61,8 +68,7 @@ namespace osu.Game.Rulesets.UI SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get())); - ShaderManager = new ShaderManager(host.Renderer, new NamespacedResourceStore(resources, @"Shaders")); - CacheAs(ShaderManager = new FallbackShaderManager(host.Renderer, ShaderManager, parent.Get())); + CacheAs(ShaderManager = new RulesetShaderManager(host.Renderer, new NamespacedResourceStore(resources, @"Shaders"), parent.Get())); RulesetConfigManager = parent.Get().GetConfigFor(ruleset); if (RulesetConfigManager != null) @@ -92,7 +98,7 @@ namespace osu.Game.Rulesets.UI isDisposed = true; - if (ShaderManager.IsNotNull()) SampleStore.Dispose(); + if (SampleStore.IsNotNull()) SampleStore.Dispose(); if (TextureStore.IsNotNull()) TextureStore.Dispose(); if (ShaderManager.IsNotNull()) ShaderManager.Dispose(); } @@ -190,25 +196,21 @@ namespace osu.Game.Rulesets.UI } } - private class FallbackShaderManager : ShaderManager + private class RulesetShaderManager : ShaderManager { - private readonly ShaderManager primary; - private readonly ShaderManager fallback; + private readonly ShaderManager parent; - public FallbackShaderManager(IRenderer renderer, ShaderManager primary, ShaderManager fallback) - : base(renderer, new ResourceStore()) + public RulesetShaderManager(IRenderer renderer, NamespacedResourceStore rulesetResources, ShaderManager parent) + : base(renderer, rulesetResources) { - this.primary = primary; - this.fallback = fallback; + this.parent = parent; } - public override byte[]? LoadRaw(string name) => primary.LoadRaw(name) ?? fallback.LoadRaw(name); + public override IShader? GetCachedShader(string vertex, string fragment) => base.GetCachedShader(vertex, fragment) ?? parent.GetCachedShader(vertex, fragment); - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - if (primary.IsNotNull()) primary.Dispose(); - } + public override IShaderPart? GetCachedShaderPart(string name) => base.GetCachedShaderPart(name) ?? parent.GetCachedShaderPart(name); + + public override byte[]? GetRawData(string fileName) => base.GetRawData(fileName) ?? parent.GetRawData(fileName); } } } diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs index e1c03e49e3..fbb7a20a5d 100644 --- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -52,14 +52,13 @@ namespace osu.Game.Rulesets.UI return; var samples = nextObject.Samples - .Select(s => nextObject.SampleControlPoint.ApplyTo(s)) .Cast() .ToArray(); PlaySamples(samples); } - protected void PlaySamples(ISampleInfo[] samples) => Schedule(() => + protected virtual void PlaySamples(ISampleInfo[] samples) => Schedule(() => { var hitSound = getNextSample(); hitSound.Samples = samples; diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index b1c3b78e67..6016a53918 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -293,10 +293,10 @@ namespace osu.Game.Rulesets.UI { // prepare sample pools ahead of time so we're not initialising at runtime. foreach (var sample in hitObject.Samples) - prepareSamplePool(hitObject.SampleControlPoint.ApplyTo(sample)); + prepareSamplePool(sample); foreach (var sample in hitObject.AuxiliarySamples) - prepareSamplePool(hitObject.SampleControlPoint.ApplyTo(sample)); + prepareSamplePool(sample); foreach (var nestedObject in hitObject.NestedHitObjects) preloadSamples(nestedObject); diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 7bf0482673..a24e22f22b 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -19,7 +19,7 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Input.Handlers; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.ClicksPerSecond; using static osu.Game.Input.Handlers.ReplayInputHandler; @@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.UI public abstract partial class RulesetInputManager : PassThroughInputManager, ICanAttachHUDPieces, IHasReplayHandler, IHasRecordingHandler where T : struct { + protected override bool AllowRightClickFromLongTouch => false; + public readonly KeyBindingContainer KeyBindingContainer; [Resolved(CanBeNull = true)] @@ -169,7 +171,7 @@ namespace osu.Game.Rulesets.UI .Select(b => b.GetAction()) .Distinct() .OrderBy(action => action) - .Select(action => new KeyCounterAction(action))); + .Select(action => new KeyCounterActionTrigger(action))); } private partial class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler @@ -179,11 +181,14 @@ namespace osu.Game.Rulesets.UI { } - public bool OnPressed(KeyBindingPressEvent e) => Target.Children.OfType>().Any(c => c.OnPressed(e.Action, Clock.Rate >= 0)); + public bool OnPressed(KeyBindingPressEvent e) => Target.Counters.Where(c => c.Trigger is KeyCounterActionTrigger) + .Select(c => (KeyCounterActionTrigger)c.Trigger) + .Any(c => c.OnPressed(e.Action, Clock.Rate >= 0)); public void OnReleased(KeyBindingReleaseEvent e) { - foreach (var c in Target.Children.OfType>()) + foreach (var c + in Target.Counters.Where(c => c.Trigger is KeyCounterActionTrigger).Select(c => (KeyCounterActionTrigger)c.Trigger)) c.OnReleased(e.Action, Clock.Rate >= 0); } } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs index f78509f919..437e5a5e38 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.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 - namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public interface IScrollAlgorithm diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs index 54079c7895..ead893c0af 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.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. -#nullable disable - -using System; +using System.Diagnostics; using System.Linq; using osu.Framework.Lists; using osu.Game.Beatmaps.ControlPoints; @@ -40,29 +38,16 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { - // Find the control point relating to the position. + Debug.Assert(controlPoints.Count > 0); + + // Iterate over control points and find the most relevant for the provided position. // Note: Due to velocity adjustments, overlapping control points will provide multiple valid time values for a single position // As such, this operation provides unexpected results by using the latter of the control points. + var relevantControlPoint = controlPoints.LastOrDefault(cp => PositionAt(cp.Time, currentTime, timeRange, scrollLength) <= position) ?? controlPoints.First(); - int i = 0; - float pos = 0; + float positionAtControlPoint = PositionAt(relevantControlPoint.Time, currentTime, timeRange, scrollLength); - for (; i < controlPoints.Count; i++) - { - float lastPos = pos; - pos = PositionAt(controlPoints[i].Time, currentTime, timeRange, scrollLength); - - if (pos > position) - { - i--; - pos = lastPos; - break; - } - } - - i = Math.Clamp(i, 0, controlPoints.Count - 1); - - return controlPoints[i].Time + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; + return relevantControlPoint.Time + (position - positionAtControlPoint) * timeRange / relevantControlPoint.Multiplier / scrollLength; } public void Reset() diff --git a/osu.Game/Scoring/IScoreInfo.cs b/osu.Game/Scoring/IScoreInfo.cs index 289679a724..ffc30384d2 100644 --- a/osu.Game/Scoring/IScoreInfo.cs +++ b/osu.Game/Scoring/IScoreInfo.cs @@ -15,6 +15,9 @@ namespace osu.Game.Scoring { IUser User { get; } + /// + /// The standardised total score. + /// long TotalScore { get; } int MaxCombo { get; } diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index e42f6caf26..84bf6d15f6 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -3,13 +3,72 @@ #nullable disable +using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring.Legacy { public static class ScoreInfoExtensions { + public static long GetDisplayScore(this ScoreProcessor scoreProcessor, ScoringMode mode) + => getDisplayScore(scoreProcessor.Ruleset.RulesetInfo.OnlineID, scoreProcessor.TotalScore.Value, mode, scoreProcessor.MaximumStatistics); + + public static long GetDisplayScore(this ScoreInfo scoreInfo, ScoringMode mode) + => getDisplayScore(scoreInfo.Ruleset.OnlineID, scoreInfo.TotalScore, mode, scoreInfo.MaximumStatistics); + + private static long getDisplayScore(int rulesetId, long score, ScoringMode mode, IReadOnlyDictionary maximumStatistics) + { + if (mode == ScoringMode.Standardised) + return score; + + int maxBasicJudgements = maximumStatistics + .Where(k => k.Key.IsBasic()) + .Select(k => k.Value) + .DefaultIfEmpty(0) + .Sum(); + + // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. + // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. + double scaledRawScore = score / ScoreProcessor.MAX_SCORE; + + return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, maxBasicJudgements), 2) * getStandardisedToClassicMultiplier(rulesetId)); + } + + /// + /// Returns a ballpark multiplier which gives a similar "feel" for how large scores should get when displayed in "classic" mode. + /// This is different per ruleset to match the different algorithms used in the scoring implementation. + /// + private static double getStandardisedToClassicMultiplier(int rulesetId) + { + double multiplier; + + switch (rulesetId) + { + // For non-legacy rulesets, just go with the same as the osu! ruleset. + // This is arbitrary, but at least allows the setting to do something to the score. + default: + case 0: + multiplier = 36; + break; + + case 1: + multiplier = 22; + break; + + case 2: + multiplier = 28; + break; + + case 3: + multiplier = 16; + break; + } + + return multiplier; + } + public static int? GetCountGeki(this ScoreInfo scoreInfo) { switch (scoreInfo.Ruleset.OnlineID) diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 02c7acf350..e084c45de0 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -7,7 +7,6 @@ using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Localisation; -using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Models; @@ -25,7 +24,6 @@ namespace osu.Game.Scoring /// /// A realm model containing metadata for a single score. /// - [ExcludeFromDynamicCompile] [MapTo("Score")] public class ScoreInfo : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IScoreInfo { diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 3217c79768..d5509538fd 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -20,6 +20,7 @@ using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; using osu.Game.Online.API; +using osu.Game.Scoring.Legacy; namespace osu.Game.Scoring { @@ -27,6 +28,7 @@ namespace osu.Game.Scoring { private readonly OsuConfigManager configManager; private readonly ScoreImporter scoreImporter; + private readonly LegacyScoreExporter scoreExporter; public override bool PauseImports { @@ -48,6 +50,11 @@ namespace osu.Game.Scoring { PostNotification = obj => PostNotification?.Invoke(obj) }; + + scoreExporter = new LegacyScoreExporter(storage) + { + PostNotification = obj => PostNotification?.Invoke(obj) + }; } public Score GetScore(ScoreInfo score) => scoreImporter.GetScore(score); @@ -68,7 +75,7 @@ namespace osu.Game.Scoring /// The array of s to reorder. /// The given ordered by decreasing total score. public IEnumerable OrderByTotalScore(IEnumerable scores) - => scores.OrderByDescending(s => GetTotalScore(s)) + => scores.OrderByDescending(s => s.TotalScore) .ThenBy(s => s.OnlineID) // Local scores may not have an online ID. Fall back to date in these cases. .ThenBy(s => s.Date); @@ -81,7 +88,7 @@ namespace osu.Game.Scoring /// /// The to retrieve the bindable for. /// The bindable containing the total score. - public Bindable GetBindableTotalScore([NotNull] ScoreInfo score) => new TotalScoreBindable(score, this, configManager); + public Bindable GetBindableTotalScore([NotNull] ScoreInfo score) => new TotalScoreBindable(score, configManager); /// /// Retrieves a bindable that represents the formatted total score string of a . @@ -93,25 +100,6 @@ namespace osu.Game.Scoring /// The bindable containing the formatted total score string. public Bindable GetBindableTotalScoreString([NotNull] ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); - /// - /// Retrieves the total score of a in the given . - /// - /// The to calculate the total score of. - /// The to return the total score as. - /// The total score. - public long GetTotalScore([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised) - { - // TODO: This is required for playlist aggregate scores. They should likely not be getting here in the first place. - if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash)) - return score.TotalScore; - - var ruleset = score.Ruleset.CreateInstance(); - var scoreProcessor = ruleset.CreateScoreProcessor(); - scoreProcessor.Mods.Value = score.Mods; - - return scoreProcessor.ComputeScore(mode, score); - } - /// /// Retrieves the maximum achievable combo for the provided score. /// @@ -130,12 +118,11 @@ namespace osu.Game.Scoring /// Creates a new . /// /// The to provide the total score of. - /// The . /// The config. - public TotalScoreBindable(ScoreInfo score, ScoreManager scoreManager, OsuConfigManager configManager) + public TotalScoreBindable(ScoreInfo score, OsuConfigManager configManager) { configManager?.BindWith(OsuSetting.ScoreDisplayMode, scoringMode); - scoringMode.BindValueChanged(mode => Value = scoreManager.GetTotalScore(score, mode.NewValue), true); + scoringMode.BindValueChanged(mode => Value = score.GetDisplayScore(mode.NewValue), true); } } @@ -187,6 +174,8 @@ namespace osu.Game.Scoring public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => scoreImporter.Import(notification, tasks); + public Task Export(ScoreInfo score) => scoreExporter.ExportAsync(score.ToLive(Realm)); + public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original); public Live Import(ScoreInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index aa8e202e22..1da224d850 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -154,12 +154,15 @@ namespace osu.Game.Screens.Edit /// /// The 0-based beat index. /// The beat divisor. + /// The list of valid divisors which can be chosen from. Assumes ordered from low to high. Defaults to if omitted. /// The applicable divisor. - public static int GetDivisorForBeatIndex(int index, int beatDivisor) + public static int GetDivisorForBeatIndex(int index, int beatDivisor, int[] validDivisors = null) { + validDivisors ??= PREDEFINED_DIVISORS; + int beat = index % beatDivisor; - foreach (int divisor in PREDEFINED_DIVISORS) + foreach (int divisor in validDivisors) { if ((beat * divisor) % beatDivisor == 0) return divisor; diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 9f422d5aa9..432c5ea280 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -383,7 +383,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { CurrentNumber.BindTo(this.beatDivisor = beatDivisor); - Padding = new MarginPadding { Horizontal = 5 }; + RangePadding = 5; + Padding = new MarginPadding { Horizontal = RangePadding }; } protected override void LoadComplete() @@ -398,15 +399,20 @@ namespace osu.Game.Screens.Edit.Compose.Components ClearInternal(); CurrentNumber.ValueChanged -= moveMarker; - foreach (int divisor in beatDivisor.ValidDivisors.Value.Presets) + int largestDivisor = beatDivisor.ValidDivisors.Value.Presets.Last(); + + for (int tickIndex = 0; tickIndex <= largestDivisor; tickIndex++) { - AddInternal(new Tick(divisor) + int divisor = BindableBeatDivisor.GetDivisorForBeatIndex(tickIndex, largestDivisor, (int[])beatDivisor.ValidDivisors.Value.Presets); + bool isSolidTick = divisor * (largestDivisor - tickIndex) == largestDivisor; + + AddInternal(new Tick(divisor, isSolidTick) { Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, RelativePositionAxes = Axes.Both, Colour = BindableBeatDivisor.GetColourFor(divisor, colours), - X = getMappedPosition(divisor), + X = tickIndex / (float)largestDivisor, }); } @@ -418,6 +424,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private void moveMarker(ValueChangedEvent divisor) { marker.MoveToX(getMappedPosition(divisor.NewValue), 100, Easing.OutQuint); + + foreach (Tick tick in InternalChildren.OfType().Where(t => !t.AlwaysDisplayed)) + { + tick.FadeTo(divisor.NewValue % tick.Divisor == 0 ? 0.2f : 0f, 100, Easing.OutQuint); + } } protected override void UpdateValue(float value) @@ -483,13 +494,22 @@ namespace osu.Game.Screens.Edit.Compose.Components OnUserChange(Current.Value); } - private float getMappedPosition(float divisor) => MathF.Pow((divisor - 1) / (beatDivisor.ValidDivisors.Value.Presets.Last() - 1), 0.90f); + private float getMappedPosition(float divisor) => 1 - 1 / divisor; private partial class Tick : Circle { - public Tick(int divisor) + public readonly bool AlwaysDisplayed; + + public readonly int Divisor; + + public Tick(int divisor, bool alwaysDisplayed) { + AlwaysDisplayed = alwaysDisplayed; + Divisor = divisor; + Size = new Vector2(6f, 12) * BindableBeatDivisor.GetSize(divisor); + Alpha = alwaysDisplayed ? 1 : 0; + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index e4e67d10d7..56a6b18433 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -16,6 +16,7 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osuTK; using osuTK.Input; @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// A container which provides a "blueprint" display of items. /// Includes selection and manipulation support via a . /// - public abstract partial class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + public abstract partial class BlueprintContainer : CompositeDrawable, IKeyBindingHandler, IKeyBindingHandler where T : class { protected DragBox DragBox { get; private set; } @@ -45,6 +46,15 @@ namespace osu.Game.Screens.Edit.Compose.Components protected readonly BindableList SelectedItems = new BindableList(); + /// + /// Whether to allow cyclic selection on clicking multiple times. + /// + /// + /// Disabled by default as it does not work well with editors that support double-clicking or other advanced interactions. + /// Can probably be made to work with more thought. + /// + protected virtual bool AllowCyclicSelection => false; + protected BlueprintContainer() { RelativeSizeAxes = Axes.Both; @@ -82,7 +92,6 @@ namespace osu.Game.Screens.Edit.Compose.Components }; SelectionHandler = CreateSelectionHandler(); - SelectionHandler.DeselectAll = DeselectAll; SelectionHandler.SelectedItems.BindTo(SelectedItems); AddRangeInternal(new[] @@ -167,8 +176,9 @@ namespace osu.Game.Screens.Edit.Compose.Components Schedule(() => { endClickSelection(e); - clickSelectionBegan = false; + clickSelectionHandled = false; isDraggingBlueprint = false; + wasDragStarted = false; }); finishSelectionMovement(); @@ -182,6 +192,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; lastDragEvent = e; + wasDragStarted = true; if (movementBlueprints != null) { @@ -268,6 +279,30 @@ namespace osu.Game.Screens.Edit.Compose.Components { } + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.Back: + if (SelectedItems.Count > 0) + { + DeselectAll(); + return true; + } + + break; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + #region Blueprint Addition/Removal protected virtual void AddBlueprintFor(T item) @@ -339,7 +374,12 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Whether a blueprint was selected by a previous click event. /// - private bool clickSelectionBegan; + private bool clickSelectionHandled; + + /// + /// Whether the selected blueprint(s) were already selected on mouse down. Generally used to perform selection cycling on mouse up in such a case. + /// + private bool selectedBlueprintAlreadySelectedOnMouseDown; /// /// Attempts to select any hovered blueprints. @@ -354,7 +394,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { if (!blueprint.IsHovered) continue; - return clickSelectionBegan = SelectionHandler.MouseDownSelectionRequested(blueprint, e); + selectedBlueprintAlreadySelectedOnMouseDown = blueprint.State == SelectionState.Selected; + return clickSelectionHandled = SelectionHandler.MouseDownSelectionRequested(blueprint, e); } return false; @@ -367,25 +408,48 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether a click selection was active. private bool endClickSelection(MouseButtonEvent e) { - if (!clickSelectionBegan && !isDraggingBlueprint) + // If already handled a selection or drag, we don't want to perform a mouse up / click action. + if (clickSelectionHandled || isDraggingBlueprint) return true; + + if (e.Button != MouseButton.Left) return false; + + if (e.ControlPressed) { // if a selection didn't occur, we may want to trigger a deselection. - if (e.ControlPressed && e.Button == MouseButton.Left) - { - // Iterate from the top of the input stack (blueprints closest to the front of the screen first). - // Priority is given to already-selected blueprints. - foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected)) - { - if (!blueprint.IsHovered) continue; - return clickSelectionBegan = SelectionHandler.MouseUpSelectionRequested(blueprint, e); - } - } + // Iterate from the top of the input stack (blueprints closest to the front of the screen first). + // Priority is given to already-selected blueprints. + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Where(b => b.IsHovered).OrderByDescending(b => b.IsSelected)) + return clickSelectionHandled = SelectionHandler.MouseUpSelectionRequested(blueprint, e); return false; } - return true; + if (!wasDragStarted && selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1 && AllowCyclicSelection) + { + // If a click occurred and was handled by the currently selected blueprint but didn't result in a drag, + // cycle between other blueprints which are also under the cursor. + + // The depth of blueprints is constantly changing (see above where selected blueprints are brought to the front). + // For this logic, we want a stable sort order so we can correctly cycle, thus using the blueprintMap instead. + IEnumerable> cyclingSelectionBlueprints = blueprintMap.Values; + + // If there's already a selection, let's start from the blueprint after the selection. + cyclingSelectionBlueprints = cyclingSelectionBlueprints.SkipWhile(b => !b.IsSelected).Skip(1); + + // Add the blueprints from before the selection to the end of the enumerable to allow for cyclic selection. + cyclingSelectionBlueprints = cyclingSelectionBlueprints.Concat(blueprintMap.Values.TakeWhile(b => !b.IsSelected)); + + foreach (SelectionBlueprint blueprint in cyclingSelectionBlueprints) + { + if (!blueprint.IsHovered) continue; + + // We are performing a mouse up, but selection handlers perform selection on mouse down, so we need to call that instead. + return clickSelectionHandled = SelectionHandler.MouseDownSelectionRequested(blueprint, e); + } + } + + return false; } /// @@ -441,8 +505,17 @@ namespace osu.Game.Screens.Edit.Compose.Components private Vector2[][] movementBlueprintsOriginalPositions; private SelectionBlueprint[] movementBlueprints; + + /// + /// Whether a blueprint is currently being dragged. + /// private bool isDraggingBlueprint; + /// + /// Whether a drag operation was started at all. + /// + private bool wasDragStarted; + /// /// Attempts to begin the movement of any selected blueprints. /// @@ -454,7 +527,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // Any selected blueprint that is hovered can begin the movement of the group, however only the first item (according to SortForMovement) is used for movement. // A special case is added for when a click selection occurred before the drag - if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) + if (!clickSelectionHandled && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) return false; // Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 453e4b9130..c8cfac454a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -14,6 +14,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; @@ -56,7 +58,8 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - TernaryStates = CreateTernaryButtons().ToArray(); + MainTernaryStates = CreateTernaryButtons().ToArray(); + SampleBankTernaryStates = createSampleBankTernaryButtons().ToArray(); AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) { @@ -78,9 +81,10 @@ namespace osu.Game.Screens.Edit.Compose.Components // we own SelectionHandler so don't need to worry about making bindable copies (for simplicity) foreach (var kvp in SelectionHandler.SelectionSampleStates) - { kvp.Value.BindValueChanged(_ => updatePlacementSamples()); - } + + foreach (var kvp in SelectionHandler.SelectionBankStates) + kvp.Value.BindValueChanged(_ => updatePlacementSamples()); } protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) @@ -166,6 +170,9 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach (var kvp in SelectionHandler.SelectionSampleStates) sampleChanged(kvp.Key, kvp.Value.Value); + + foreach (var kvp in SelectionHandler.SelectionBankStates) + bankChanged(kvp.Key, kvp.Value.Value); } private void sampleChanged(string sampleName, TernaryState state) @@ -185,17 +192,29 @@ namespace osu.Game.Screens.Edit.Compose.Components case TernaryState.True: if (existingSample == null) - samples.Add(new HitSampleInfo(sampleName)); + samples.Add(CurrentPlacement.HitObject.CreateHitSampleInfo(sampleName)); break; } } + private void bankChanged(string bankName, TernaryState state) + { + if (CurrentPlacement == null) return; + + if (bankName == EditorSelectionHandler.HIT_BANK_AUTO) + CurrentPlacement.AutomaticBankAssignment = state == TernaryState.True; + else if (state == TernaryState.True) + CurrentPlacement.HitObject.Samples = CurrentPlacement.HitObject.Samples.Select(s => s.With(newBank: bankName)).ToList(); + } + public readonly Bindable NewCombo = new Bindable { Description = "New Combo" }; /// /// A collection of states which will be displayed to the user in the toolbox. /// - public TernaryButton[] TernaryStates { get; private set; } + public TernaryButton[] MainTernaryStates { get; private set; } + + public TernaryButton[] SampleBankTernaryStates { get; private set; } /// /// Create all ternary states required to be displayed to the user. @@ -209,6 +228,39 @@ namespace osu.Game.Screens.Edit.Compose.Components yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key)); } + private IEnumerable createSampleBankTernaryButtons() + { + foreach (var kvp in SelectionHandler.SelectionBankStates) + yield return new TernaryButton(kvp.Value, kvp.Key.Titleize(), () => getIconForBank(kvp.Key)); + } + + private Drawable getIconForBank(string sampleName) + { + return new Container + { + Size = new Vector2(30, 20), + Children = new Drawable[] + { + new SpriteIcon + { + Size = new Vector2(8), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.VolumeOff + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + X = 10, + Y = -1, + Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 20), + Text = $"{char.ToUpperInvariant(sampleName.First())}" + } + } + }; + } + private Drawable getIconForSample(string sampleName) { switch (sampleName) @@ -317,12 +369,16 @@ namespace osu.Game.Screens.Edit.Compose.Components } } + private void commitIfPlacementActive() + { + CurrentPlacement?.EndPlacement(CurrentPlacement.PlacementActive == PlacementBlueprint.PlacementState.Active); + removePlacement(); + } + private void removePlacement() { - if (CurrentPlacement == null) return; - - CurrentPlacement.EndPlacement(false); - CurrentPlacement.Expire(); + CurrentPlacement?.EndPlacement(false); + CurrentPlacement?.Expire(); CurrentPlacement = null; } @@ -342,7 +398,8 @@ namespace osu.Game.Screens.Edit.Compose.Components currentTool = value; - refreshTool(); + // As per stable editor, when changing tools, we should forcefully commit any pending placement. + commitIfPlacementActive(); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs new file mode 100644 index 0000000000..442454f97a --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + internal partial class EditorInspector : CompositeDrawable + { + protected OsuTextFlowContainer InspectorText = null!; + + [Resolved] + protected EditorBeatmap EditorBeatmap { get; private set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + InternalChild = InspectorText = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }; + } + + protected void AddHeader(string header) => InspectorText.AddParagraph($"{header}: ", s => + { + s.Padding = new MarginPadding { Top = 2 }; + s.Font = s.Font.With(size: 12); + s.Colour = colourProvider.Content2; + }); + + protected void AddValue(string value) => InspectorText.AddParagraph(value, s => + { + s.Font = s.Font.With(weight: FontWeight.SemiBold); + s.Colour = colourProvider.Content1; + }); + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 357cc940f2..d618541685 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -20,6 +21,12 @@ namespace osu.Game.Screens.Edit.Compose.Components { public partial class EditorSelectionHandler : SelectionHandler { + /// + /// 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. + /// + public const string HIT_BANK_AUTO = "auto"; + [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } @@ -48,11 +55,83 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public readonly Dictionary> SelectionSampleStates = new Dictionary>(); + /// + /// The state of each sample bank type for all selected hitobjects. + /// + public readonly Dictionary> SelectionBankStates = new Dictionary>(); + /// /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) /// private void createStateBindables() { + foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + { + var bindable = new Bindable + { + Description = bankName.Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + if (SelectedItems.Count == 0) + { + // Ensure that if this is the last selected bank, it should remain selected. + if (SelectionBankStates.Values.All(b => b.Value == TernaryState.False)) + bindable.Value = TernaryState.True; + } + else + { + // Auto should never apply when there is a selection made. + // This is also required to stop a bindable feedback loop when a HitObject has zero samples (and LINQ `All` below becomes true). + if (bankName == HIT_BANK_AUTO) + break; + + // Never remove a sample bank. + // These are basically radio buttons, not toggles. + if (SelectedItems.All(h => h.Samples.All(s => s.Bank == bankName))) + bindable.Value = TernaryState.True; + } + + break; + + case TernaryState.True: + if (SelectedItems.Count == 0) + { + // Ensure the user can't stack multiple bank selections when there's no hitobject selection. + // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. + foreach (var other in SelectionBankStates.Values) + { + if (other != bindable) + other.Value = TernaryState.False; + } + } + else + { + // Auto should just not apply if there's a selection already made. + // Maybe we could make it a disabled button in the future, but right now the editor buttons don't support disabled state. + if (bankName == HIT_BANK_AUTO) + { + bindable.Value = TernaryState.False; + break; + } + + AddSampleBank(bankName); + } + + break; + } + }; + + SelectionBankStates[bankName] = bindable; + } + + // start with normal selected. + SelectionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True; + foreach (string sampleName in HitSampleInfo.AllAdditions) { var bindable = new Bindable @@ -104,12 +183,33 @@ namespace osu.Game.Screens.Edit.Compose.Components { bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.Any(s => s.Name == sampleName)); } + + foreach ((string bankName, var bindable) in SelectionBankStates) + { + bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.All(s => s.Bank == bankName)); + } } #endregion #region Ternary state changes + /// + /// Adds a sample bank to all selected s. + /// + /// The name of the sample bank. + public void AddSampleBank(string bankName) + { + EditorBeatmap.PerformOnSelection(h => + { + if (h.Samples.All(s => s.Bank == bankName)) + return; + + h.Samples = h.Samples.Select(s => s.With(newBank: bankName)).ToList(); + EditorBeatmap.Update(h); + }); + } + /// /// Adds a hit sample to all selected s. /// @@ -122,7 +222,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (h.Samples.Any(s => s.Name == sampleName)) return; - h.Samples.Add(new HitSampleInfo(sampleName)); + h.Samples.Add(h.CreateHitSampleInfo(sampleName)); EditorBeatmap.Update(h); }); } @@ -174,11 +274,17 @@ namespace osu.Game.Screens.Edit.Compose.Components yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; } - yield return new OsuMenuItem("Sound") + yield return new OsuMenuItem("Sample") { Items = SelectionSampleStates.Select(kvp => new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; + + yield return new OsuMenuItem("Bank") + { + Items = SelectionBankStates.Select(kvp => + new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() + }; } #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs new file mode 100644 index 0000000000..7beaf7d086 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.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.Linq; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Threading; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + internal partial class HitObjectInspector : EditorInspector + { + protected override void LoadComplete() + { + base.LoadComplete(); + + EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, _) => updateInspectorText(); + EditorBeatmap.TransactionBegan += updateInspectorText; + EditorBeatmap.TransactionEnded += updateInspectorText; + updateInspectorText(); + } + + private ScheduledDelegate? rollingTextUpdate; + + private void updateInspectorText() + { + InspectorText.Clear(); + rollingTextUpdate?.Cancel(); + rollingTextUpdate = null; + + switch (EditorBeatmap.SelectedHitObjects.Count) + { + case 0: + AddValue("No selection"); + break; + + case 1: + var selected = EditorBeatmap.SelectedHitObjects.Single(); + + AddHeader("Type"); + AddValue($"{selected.GetType().ReadableName()}"); + + AddHeader("Time"); + AddValue($"{selected.StartTime:#,0.##}ms"); + + switch (selected) + { + case IHasPosition pos: + AddHeader("Position"); + AddValue($"x:{pos.X:#,0.##} y:{pos.Y:#,0.##}"); + break; + + case IHasXPosition x: + AddHeader("Position"); + + AddValue($"x:{x.X:#,0.##} "); + break; + + case IHasYPosition y: + AddHeader("Position"); + + AddValue($"y:{y.Y:#,0.##}"); + break; + } + + if (selected is IHasDistance distance) + { + AddHeader("Distance"); + AddValue($"{distance.Distance:#,0.##}px"); + } + + if (selected is IHasSliderVelocity sliderVelocity) + { + AddHeader("Slider Velocity"); + AddValue($"{sliderVelocity.SliderVelocity:#,0.00}x ({sliderVelocity.SliderVelocity * EditorBeatmap.Difficulty.SliderMultiplier:#,0.00}x)"); + } + + if (selected is IHasRepeats repeats) + { + AddHeader("Repeats"); + AddValue($"{repeats.RepeatCount:#,0.##}"); + } + + if (selected is IHasDuration duration) + { + AddHeader("End Time"); + AddValue($"{duration.EndTime:#,0.##}ms"); + AddHeader("Duration"); + AddValue($"{duration.Duration:#,0.##}ms"); + } + + // I'd hope there's a better way to do this, but I don't want to bind to each and every property above to watch for changes. + // This is a good middle-ground for the time being. + rollingTextUpdate ??= Scheduler.AddDelayed(updateInspectorText, 250); + break; + + default: + AddHeader("Selected Objects"); + AddValue($"{EditorBeatmap.SelectedHitObjects.Count:#,0.##}"); + + AddHeader("Start Time"); + AddValue($"{EditorBeatmap.SelectedHitObjects.Min(o => o.StartTime):#,0.##}ms"); + + AddHeader("End Time"); + AddValue($"{EditorBeatmap.SelectedHitObjects.Max(o => o.GetEndTime()):#,0.##}ms"); + break; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 17790547ed..1c5faed0e5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -22,6 +22,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { public const float BORDER_RADIUS = 3; + private const float button_padding = 5; + public Func OnRotation; public Func OnScale; public Func OnFlip; @@ -182,6 +184,13 @@ namespace osu.Game.Screens.Edit.Compose.Components return base.OnKeyDown(e); } + protected override void Update() + { + base.Update(); + + ensureButtonsOnScreen(); + } + private void recreate() { if (LoadState < LoadState.Loading) @@ -234,11 +243,10 @@ namespace osu.Game.Screens.Edit.Compose.Components }, buttons = new FillFlowContainer { - Y = 20, - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.X, + Height = 30, Direction = FillDirection.Horizontal, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre + Margin = new MarginPadding(button_padding), } }; @@ -352,5 +360,39 @@ namespace osu.Game.Screens.Edit.Compose.Components if (activeOperations++ == 0) OperationStarted?.Invoke(); } + + private void ensureButtonsOnScreen() + { + buttons.Position = Vector2.Zero; + + var thisQuad = ScreenSpaceDrawQuad; + + // Shrink the parent quad to give a bit of padding so the buttons don't stick *right* on the border. + // AABBFloat assumes no rotation. one would hope the whole editor is not being rotated. + var parentQuad = Parent.ScreenSpaceDrawQuad.AABBFloat.Shrink(ToLocalSpace(thisQuad.TopLeft + new Vector2(button_padding * 2))); + + float topExcess = thisQuad.TopLeft.Y - parentQuad.TopLeft.Y; + float bottomExcess = parentQuad.BottomLeft.Y - thisQuad.BottomLeft.Y; + float leftExcess = thisQuad.TopLeft.X - parentQuad.TopLeft.X; + float rightExcess = parentQuad.TopRight.X - thisQuad.TopRight.X; + + if (topExcess + bottomExcess < buttons.Height + button_padding) + { + buttons.Anchor = Anchor.BottomCentre; + buttons.Origin = Anchor.BottomCentre; + } + else if (topExcess > bottomExcess) + { + buttons.Anchor = Anchor.TopCentre; + buttons.Origin = Anchor.BottomCentre; + } + else + { + buttons.Anchor = Anchor.BottomCentre; + buttons.Origin = Anchor.TopCentre; + } + + buttons.X += ToLocalSpace(thisQuad.TopLeft - new Vector2(Math.Min(0, leftExcess)) + new Vector2(Math.Min(0, rightExcess))).X; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index 0f702e1c49..c2a3f12efd 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -7,14 +7,15 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Localisation; using osuTK; using osuTK.Graphics; +using Key = osuTK.Input.Key; namespace osu.Game.Screens.Edit.Compose.Components { @@ -26,6 +27,8 @@ namespace osu.Game.Screens.Edit.Compose.Components private SpriteIcon icon; + private const float snap_step = 15; + private readonly Bindable cumulativeRotation = new Bindable(); [Resolved] @@ -50,18 +53,14 @@ namespace osu.Game.Screens.Edit.Compose.Components }); } - protected override void LoadComplete() - { - base.LoadComplete(); - cumulativeRotation.BindValueChanged(_ => updateTooltipText(), true); - } - protected override void UpdateHoverState() { base.UpdateHoverState(); icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); } + private float rawCumulativeRotation; + protected override bool OnDragStart(DragStartEvent e) { bool handle = base.OnDragStart(e); @@ -74,21 +73,36 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.OnDrag(e); - float instantaneousAngle = convertDragEventToAngleOfRotation(e); - cumulativeRotation.Value += instantaneousAngle; + rawCumulativeRotation += convertDragEventToAngleOfRotation(e); - if (cumulativeRotation.Value < -180) - cumulativeRotation.Value += 360; - else if (cumulativeRotation.Value > 180) - cumulativeRotation.Value -= 360; + applyRotation(shouldSnap: e.ShiftPressed); + } - HandleRotate?.Invoke(instantaneousAngle); + protected override bool OnKeyDown(KeyDownEvent e) + { + if (IsDragged && (e.Key == Key.ShiftLeft || e.Key == Key.ShiftRight)) + { + applyRotation(shouldSnap: true); + return true; + } + + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + base.OnKeyUp(e); + + if (IsDragged && (e.Key == Key.ShiftLeft || e.Key == Key.ShiftRight)) + applyRotation(shouldSnap: false); } protected override void OnDragEnd(DragEndEvent e) { base.OnDragEnd(e); cumulativeRotation.Value = null; + rawCumulativeRotation = 0; + TooltipText = default; } private float convertDragEventToAngleOfRotation(DragEvent e) @@ -100,9 +114,19 @@ namespace osu.Game.Screens.Edit.Compose.Components return (endAngle - startAngle) * 180 / MathF.PI; } - private void updateTooltipText() + private void applyRotation(bool shouldSnap) { - TooltipText = cumulativeRotation.Value?.ToLocalisableString("0.0°") ?? default; + float oldRotation = cumulativeRotation.Value ?? 0; + + float newRotation = shouldSnap ? snap(rawCumulativeRotation, snap_step) : MathF.Round(rawCumulativeRotation); + newRotation = (newRotation - 180) % 360 + 180; + + cumulativeRotation.Value = newRotation; + + HandleRotate?.Invoke(newRotation - oldRotation); + TooltipText = shouldSnap ? EditorStrings.RotationSnapped(newRotation) : EditorStrings.RotationUnsnapped(newRotation); } + + private float snap(float value, float step) => MathF.Round(value / step) * step; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 9e4fb26688..5cedf1ca42 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -197,9 +197,9 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Handling /// - /// Bind an action to deselect all selected blueprints. + /// Deselect all selected items. /// - internal Action DeselectAll { private get; set; } + protected void DeselectAll() => SelectedItems.Clear(); /// /// Handle a blueprint becoming selected. @@ -303,7 +303,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (blueprint.IsSelected) return false; - DeselectAll?.Invoke(); + DeselectAll(); blueprint.Select(); return true; } @@ -311,6 +311,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected void DeleteSelected() { DeleteItems(SelectedItems.ToArray()); + DeselectAll(); } #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index d3cdd461ea..173a665d5c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -13,12 +13,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; -using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Timing; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -29,13 +31,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly BindableNumber speedMultiplier; public DifficultyPointPiece(HitObject hitObject) - : base(hitObject.DifficultyControlPoint) { HitObject = hitObject; - speedMultiplier = hitObject.DifficultyControlPoint.SliderVelocityBindable.GetBoundCopy(); + speedMultiplier = (hitObject as IHasSliderVelocity)?.SliderVelocityBindable.GetBoundCopy(); } + protected override Color4 GetRepresentingColour(OsuColour colours) => colours.Lime1; + protected override void LoadComplete() { base.LoadComplete(); @@ -78,7 +81,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Spacing = new Vector2(0, 15), Children = new Drawable[] { - sliderVelocitySlider = new IndeterminateSliderWithTextBoxInput("Velocity", new DifficultyControlPoint().SliderVelocityBindable) + sliderVelocitySlider = new IndeterminateSliderWithTextBoxInput("Velocity", new BindableDouble(1) + { + Precision = 0.01, + MinValue = 0.1, + MaxValue = 10 + }) { KeyboardStep = 0.1f }, @@ -87,18 +95,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Text = "Hold shift while dragging the end of an object to adjust velocity while snapping." - } + }, + new SliderVelocityInspector(sliderVelocitySlider.Current), } } }; // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. // if the piece belongs to an unselected object, operate on that object alone, independently of the selection. - var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); - var relevantControlPoints = relevantObjects.Select(h => h.DifficultyControlPoint).ToArray(); + var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).Where(o => o is IHasSliderVelocity).ToArray(); // even if there are multiple objects selected, we can still display a value if they all have the same value. - var selectedPointBindable = relevantControlPoints.Select(point => point.SliderVelocity).Distinct().Count() == 1 ? relevantControlPoints.First().SliderVelocityBindable : null; + var selectedPointBindable = relevantObjects.Select(point => ((IHasSliderVelocity)point).SliderVelocity).Distinct().Count() == 1 + ? ((IHasSliderVelocity)relevantObjects.First()).SliderVelocityBindable + : null; if (selectedPointBindable != null) { @@ -117,7 +127,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline foreach (var h in relevantObjects) { - h.DifficultyControlPoint.SliderVelocity = val.NewValue.Value; + ((IHasSliderVelocity)h).SliderVelocity = val.NewValue.Value; beatmap.Update(h); } @@ -132,4 +142,60 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } } + + internal partial class SliderVelocityInspector : EditorInspector + { + private readonly Bindable current; + + public SliderVelocityInspector(Bindable current) + { + this.current = current; + } + + [BackgroundDependencyLoader] + private void load() + { + EditorBeatmap.TransactionBegan += updateInspectorText; + EditorBeatmap.TransactionEnded += updateInspectorText; + EditorBeatmap.BeatmapReprocessed += updateInspectorText; + current.ValueChanged += _ => updateInspectorText(); + + updateInspectorText(); + } + + private void updateInspectorText() + { + double beatmapVelocity = EditorBeatmap.Difficulty.SliderMultiplier; + + InspectorText.Clear(); + + double[] sliderVelocities = EditorBeatmap.HitObjects.OfType().Select(sv => sv.SliderVelocity).OrderBy(v => v).ToArray(); + + AddHeader("Base velocity (from beatmap setup)"); + AddValue($"{beatmapVelocity:#,0.00}x"); + + AddHeader("Final velocity"); + AddValue($"{beatmapVelocity * current.Value:#,0.00}x"); + + if (sliderVelocities.First() != sliderVelocities.Last()) + { + AddHeader("Beatmap velocity range"); + + string range = $"{sliderVelocities.First():#,0.00}x - {sliderVelocities.Last():#,0.00}x"; + if (beatmapVelocity != 1) + range += $" ({beatmapVelocity * sliderVelocities.First():#,0.00}x - {beatmapVelocity * sliderVelocities.Last():#,0.00}x)"; + + AddValue(range); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + EditorBeatmap.TransactionBegan -= updateInspectorText; + EditorBeatmap.TransactionEnded -= updateInspectorText; + EditorBeatmap.BeatmapReprocessed -= updateInspectorText; + } + } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs index 5b0a5729c8..4b357d3a62 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -16,21 +15,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public partial class HitObjectPointPiece : CircularContainer { - private readonly ControlPoint point; - protected OsuSpriteText Label { get; private set; } - protected HitObjectPointPiece(ControlPoint point) - { - this.point = point; - } - [BackgroundDependencyLoader] private void load(OsuColour colours) { AutoSizeAxes = Axes.Both; - Color4 colour = point.GetRepresentingColour(colours); + Color4 colour = GetRepresentingColour(colours); InternalChildren = new Drawable[] { @@ -61,5 +53,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, }; } + + protected virtual Color4 GetRepresentingColour(OsuColour colours) + { + return colours.Yellow; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 314137a565..b02cfb505e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -12,11 +12,13 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; -using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Audio; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Timing; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -24,22 +26,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public readonly HitObject HitObject; - private readonly Bindable bank; - private readonly BindableNumber volume; + private readonly BindableList samplesBindable; public SamplePointPiece(HitObject hitObject) - : base(hitObject.SampleControlPoint) { HitObject = hitObject; - volume = hitObject.SampleControlPoint.SampleVolumeBindable.GetBoundCopy(); - bank = hitObject.SampleControlPoint.SampleBankBindable.GetBoundCopy(); + samplesBindable = hitObject.SamplesBindable.GetBoundCopy(); } + protected override Color4 GetRepresentingColour(OsuColour colours) => colours.Pink; + [BackgroundDependencyLoader] private void load() { - volume.BindValueChanged(_ => updateText()); - bank.BindValueChanged(_ => updateText(), true); + samplesBindable.BindCollectionChanged((_, _) => updateText(), true); } protected override bool OnClick(ClickEvent e) @@ -50,7 +50,17 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateText() { - Label.Text = $"{bank.Value} {volume.Value}"; + Label.Text = $"{GetBankValue(samplesBindable)} {GetVolumeValue(samplesBindable)}"; + } + + public static string? GetBankValue(IEnumerable samples) + { + return samples.FirstOrDefault()?.Bank; + } + + public static int GetVolumeValue(ICollection samples) + { + return samples.Count == 0 ? 0 : samples.Max(o => o.Volume); } public Popover GetPopover() => new SampleEditPopover(HitObject); @@ -89,7 +99,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { Label = "Bank Name", }, - volume = new IndeterminateSliderWithTextBoxInput("Volume", new SampleControlPoint().SampleVolumeBindable) + volume = new IndeterminateSliderWithTextBoxInput("Volume", new BindableInt(100) + { + MinValue = 0, + MaxValue = 100, + }) } } }; @@ -100,14 +114,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. // if the piece belongs to an unselected object, operate on that object alone, independently of the selection. var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); - var relevantControlPoints = relevantObjects.Select(h => h.SampleControlPoint).ToArray(); + var relevantSamples = relevantObjects.Select(h => h.Samples).ToArray(); // even if there are multiple objects selected, we can still display sample volume or bank if they all have the same value. - string? commonBank = getCommonBank(relevantControlPoints); + string? commonBank = getCommonBank(relevantSamples); if (!string.IsNullOrEmpty(commonBank)) bank.Current.Value = commonBank; - int? commonVolume = getCommonVolume(relevantControlPoints); + int? commonVolume = getCommonVolume(relevantSamples); if (commonVolume != null) volume.Current.Value = commonVolume.Value; @@ -117,9 +131,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline updateBankFor(relevantObjects, val.NewValue); updateBankPlaceholderText(relevantObjects); }); - // on commit, ensure that the value is correct by sourcing it from the objects' control points again. + // on commit, ensure that the value is correct by sourcing it from the objects' samples again. // this ensures that committing empty text causes a revert to the previous value. - bank.OnCommit += (_, _) => bank.Current.Value = getCommonBank(relevantControlPoints); + bank.OnCommit += (_, _) => bank.Current.Value = getCommonBank(relevantSamples); volume.Current.BindValueChanged(val => updateVolumeFor(relevantObjects, val.NewValue)); } @@ -130,8 +144,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(volume)); } - private static string? getCommonBank(SampleControlPoint[] relevantControlPoints) => relevantControlPoints.Select(point => point.SampleBank).Distinct().Count() == 1 ? relevantControlPoints.First().SampleBank : null; - private static int? getCommonVolume(SampleControlPoint[] relevantControlPoints) => relevantControlPoints.Select(point => point.SampleVolume).Distinct().Count() == 1 ? relevantControlPoints.First().SampleVolume : null; + private static string? getCommonBank(IList[] relevantSamples) => relevantSamples.Select(GetBankValue).Distinct().Count() == 1 ? GetBankValue(relevantSamples.First()) : null; + private static int? getCommonVolume(IList[] relevantSamples) => relevantSamples.Select(GetVolumeValue).Distinct().Count() == 1 ? GetVolumeValue(relevantSamples.First()) : null; private void updateBankFor(IEnumerable objects, string? newBank) { @@ -142,7 +156,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline foreach (var h in objects) { - h.SampleControlPoint.SampleBank = newBank; + for (int i = 0; i < h.Samples.Count; i++) + { + h.Samples[i] = h.Samples[i].With(newBank: newBank); + } + beatmap.Update(h); } @@ -151,7 +169,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateBankPlaceholderText(IEnumerable objects) { - string? commonBank = getCommonBank(objects.Select(h => h.SampleControlPoint).ToArray()); + string? commonBank = getCommonBank(objects.Select(h => h.Samples).ToArray()); bank.PlaceholderText = string.IsNullOrEmpty(commonBank) ? "(multiple)" : string.Empty; } @@ -164,7 +182,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline foreach (var h in objects) { - h.SampleControlPoint.SampleVolume = newVolume.Value; + for (int i = 0; i < h.Samples.Count; i++) + { + h.Samples[i] = h.Samples[i].With(newVolume: newVolume.Value); + } + beatmap.Update(h); } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index f93fb0679f..b60e04afc1 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -83,8 +83,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { placementBlueprint = CreateBlueprintFor(obj.NewValue).AsNonNull(); - placementBlueprint.Colour = Color4.MediumPurple; + placementBlueprint.Colour = OsuColour.Gray(0.9f); + // TODO: this is out of order, causing incorrect stacking height. SelectionBlueprints.Add(placementBlueprint); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 4e5087c004..55f122669d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -15,7 +15,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Framework.Utils; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; @@ -51,6 +50,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Container colouredComponents; private readonly OsuSpriteText comboIndexText; + private readonly SamplePointPiece samplePointPiece; + private readonly DifficultyPointPiece? difficultyPointPiece; [Resolved] private ISkinSource skin { get; set; } = null!; @@ -102,6 +103,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, } }, + samplePointPiece = new SamplePointPiece(Item) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopCentre + }, }); if (item is IHasDuration) @@ -111,6 +117,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline OnDragHandled = e => OnDragHandled?.Invoke(e) }); } + + if (item is IHasSliderVelocity) + { + AddInternal(difficultyPointPiece = new DifficultyPointPiece(Item) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.BottomCentre + }); + } } protected override void LoadComplete() @@ -187,12 +202,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline colouredComponents.Colour = OsuColour.ForegroundTextColourFor(averageColour); } - private SamplePointPiece? sampleOverrideDisplay; - private DifficultyPointPiece? difficultyOverrideDisplay; - - private DifficultyControlPoint difficultyControlPoint = null!; - private SampleControlPoint sampleControlPoint = null!; - protected override void Update() { base.Update(); @@ -208,36 +217,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (Item is IHasRepeats repeats) updateRepeats(repeats); } - - if (!ReferenceEquals(difficultyControlPoint, Item.DifficultyControlPoint)) - { - difficultyControlPoint = Item.DifficultyControlPoint; - difficultyOverrideDisplay?.Expire(); - - if (Item.DifficultyControlPoint != null && Item is IHasDistance) - { - AddInternal(difficultyOverrideDisplay = new DifficultyPointPiece(Item) - { - Anchor = Anchor.TopLeft, - Origin = Anchor.BottomCentre - }); - } - } - - if (!ReferenceEquals(sampleControlPoint, Item.SampleControlPoint)) - { - sampleControlPoint = Item.SampleControlPoint; - sampleOverrideDisplay?.Expire(); - - if (Item.SampleControlPoint != null) - { - AddInternal(sampleOverrideDisplay = new SamplePointPiece(Item) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopCentre - }); - } - } } private void updateRepeats(IHasRepeats repeats) @@ -267,6 +246,25 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft; + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) + { + // Since children are exceeding the component size, we need to use a custom quad to compute whether it should be masked away. + + // If the component isn't considered masked away by itself, there's no need to apply custom logic. + if (!base.ComputeIsMaskedAway(maskingBounds)) + return false; + + // If the component is considered masked away, we'll use children to create an extended quad that encapsulates all parts of this blueprint + // to ensure it doesn't pop in and out of existence abruptly when scrolling the timeline. + var rect = RectangleF.Union(ScreenSpaceDrawQuad.AABBFloat, circle.ScreenSpaceDrawQuad.AABBFloat); + rect = RectangleF.Union(rect, samplePointPiece.ScreenSpaceDrawQuad.AABBFloat); + + if (difficultyPointPiece != null) + rect = RectangleF.Union(rect, difficultyPointPiece.ScreenSpaceDrawQuad.AABBFloat); + + return !Precision.AlmostIntersects(maskingBounds, rect); + } + private partial class Tick : Circle { public Tick() @@ -395,17 +393,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline case IHasRepeats repeatHitObject: double proposedDuration = time - hitObject.StartTime; - if (e.CurrentState.Keyboard.ShiftPressed) + if (e.CurrentState.Keyboard.ShiftPressed && hitObject is IHasSliderVelocity hasSliderVelocity) { - if (ReferenceEquals(hitObject.DifficultyControlPoint, DifficultyControlPoint.DEFAULT)) - hitObject.DifficultyControlPoint = new DifficultyControlPoint(); + double newVelocity = hasSliderVelocity.SliderVelocity * (repeatHitObject.Duration / proposedDuration); - double newVelocity = hitObject.DifficultyControlPoint.SliderVelocity * (repeatHitObject.Duration / proposedDuration); - - if (Precision.AlmostEquals(newVelocity, hitObject.DifficultyControlPoint.SliderVelocity)) + if (Precision.AlmostEquals(newVelocity, hasSliderVelocity.SliderVelocity)) return; - hitObject.DifficultyControlPoint.SliderVelocity = newVelocity; + hasSliderVelocity.SliderVelocity = newVelocity; beatmap.Update(hitObject); } else @@ -414,7 +409,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) + if (proposedCount == repeatHitObject.RepeatCount || lengthOfOneRepeat == 0) return; repeatHitObject.RepeatCount = proposedCount; diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index d89392f757..b8fa7f6579 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -20,7 +20,6 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Logging; -using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Threading; @@ -29,7 +28,6 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; -using osu.Game.Database; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -88,9 +86,6 @@ namespace osu.Game.Screens.Edit [Resolved] private RulesetStore rulesets { get; set; } - [Resolved] - private Storage storage { get; set; } - [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } @@ -210,7 +205,10 @@ namespace osu.Game.Screens.Edit // this is a bit haphazard, but guards against setting the lease Beatmap bindable if // the editor has already been exited. if (!ValidForPush) + { + beatmapManager.Delete(loadableBeatmap.BeatmapSetInfo); return; + } } try @@ -980,7 +978,7 @@ namespace osu.Game.Screens.Edit private void exportBeatmap() { Save(); - new LegacyBeatmapExporter(storage).Export(Beatmap.Value.BeatmapSetInfo); + beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); } /// diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index b4647c2b64..2cf823ca0c 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -15,7 +16,9 @@ using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; using osu.Game.IO; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; using Decoder = osu.Game.Beatmaps.Formats.Decoder; @@ -42,6 +45,7 @@ namespace osu.Game.Screens.Edit editorBeatmap.BeginChange(); processHitObjects(result, () => newBeatmap ??= readBeatmap(newState)); processTimingPoints(() => newBeatmap ??= readBeatmap(newState)); + processHitObjectLocalData(() => newBeatmap ??= readBeatmap(newState)); editorBeatmap.EndChange(); } @@ -87,6 +91,41 @@ namespace osu.Game.Screens.Edit } } + private void processHitObjectLocalData(Func getNewBeatmap) + { + // This method handles data that are stored in control points in the legacy format, + // but were moved to the hitobjects themselves in lazer. + // Specifically, the data being referred to here consists of: slider velocity and sample information. + + // For simplicity, this implementation relies on the editor beatmap already having the same hitobjects in sequence as the new beatmap. + // To guarantee that, `processHitObjects()` must be ran prior to this method for correct operation. + // This is done to avoid the necessity of reimplementing/reusing parts of LegacyBeatmapDecoder that already treat this data correctly. + + var oldObjects = editorBeatmap.HitObjects; + var newObjects = getNewBeatmap().HitObjects; + + Debug.Assert(oldObjects.Count == newObjects.Count); + + foreach (var (oldObject, newObject) in oldObjects.Zip(newObjects)) + { + // if `oldObject` and `newObject` are the same, it means that `oldObject` was inserted into `editorBeatmap` by `processHitObjects()`. + // in that case, there is nothing to do (and some of the subsequent changes may even prove destructive). + if (ReferenceEquals(oldObject, newObject)) + continue; + + if (oldObject is IHasSliderVelocity oldWithVelocity && newObject is IHasSliderVelocity newWithVelocity) + oldWithVelocity.SliderVelocity = newWithVelocity.SliderVelocity; + + oldObject.Samples = newObject.Samples; + + if (oldObject is IHasRepeats oldWithRepeats && newObject is IHasRepeats newWithRepeats) + { + oldWithRepeats.NodeSamples.Clear(); + oldWithRepeats.NodeSamples.AddRange(newWithRepeats.NodeSamples); + } + } + } + private void findChangedIndices(DiffResult result, LegacyDecoder.Section section, out List removedIndices, out List addedIndices) { removedIndices = new List(); diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index 7026bde681..4c062b0cb7 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -19,6 +19,8 @@ namespace osu.Game.Screens.Edit.Setup private LabelledSliderBar healthDrainSlider = null!; private LabelledSliderBar approachRateSlider = null!; private LabelledSliderBar overallDifficultySlider = null!; + private LabelledSliderBar baseVelocitySlider = null!; + private LabelledSliderBar tickRateSlider = null!; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; @@ -79,13 +81,42 @@ namespace osu.Game.Screens.Edit.Setup Precision = 0.1f, } }, + baseVelocitySlider = new LabelledSliderBar + { + Label = EditorSetupStrings.BaseVelocity, + FixedLabelWidth = LABEL_WIDTH, + Description = EditorSetupStrings.BaseVelocityDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) + { + Default = 1, + MinValue = 0.4, + MaxValue = 3.6, + Precision = 0.01f, + } + }, + tickRateSlider = new LabelledSliderBar + { + Label = EditorSetupStrings.TickRate, + FixedLabelWidth = LABEL_WIDTH, + Description = EditorSetupStrings.TickRateDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) + { + Default = 1, + MinValue = 1, + MaxValue = 4, + Precision = 1, + } + }, }; foreach (var item in Children.OfType>()) - item.Current.ValueChanged += onValueChanged; + item.Current.ValueChanged += _ => updateValues(); + + foreach (var item in Children.OfType>()) + item.Current.ValueChanged += _ => updateValues(); } - private void onValueChanged(ValueChangedEvent args) + private void updateValues() { // for now, update these on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. @@ -93,6 +124,8 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value; Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; + Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; + Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value; Beatmap.UpdateAllHitObjects(); Beatmap.SaveState(); diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index b70c1f7ddf..372cfe748e 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -130,6 +130,8 @@ namespace osu.Game.Screens loadTargets.Add(manager.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE)); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE)); } diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index da44161507..2a6ebecb92 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -131,7 +131,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.Centre, Origin = Anchor.Centre, Colour = Color4.DarkBlue, - Size = new Vector2(0.96f) + Size = OsuLogo.SCALE_ADJUST, }, new Circle { diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 9430a1cda8..277b8bf888 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -35,6 +35,12 @@ namespace osu.Game.Screens.Menu private const double transition_length = 300; + /// + /// The osu! logo sprite has a shadow included in its texture. + /// This adjustment vector is used to match the precise edge of the border of the logo. + /// + public static readonly Vector2 SCALE_ADJUST = new Vector2(0.96f); + private readonly Sprite logo; private readonly CircularContainer logoContainer; private readonly Container logoBounceContainer; @@ -150,7 +156,7 @@ namespace osu.Game.Screens.Menu Origin = Anchor.Centre, Anchor = Anchor.Centre, Alpha = visualizer_default_alpha, - Size = new Vector2(0.96f) + Size = SCALE_ADJUST }, new Container { @@ -162,7 +168,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Scale = new Vector2(0.88f), + Scale = SCALE_ADJUST, Masking = true, Children = new Drawable[] { @@ -406,7 +412,7 @@ namespace osu.Game.Screens.Menu public void Impact() { impactContainer.FadeOutFromOne(250, Easing.In); - impactContainer.ScaleTo(0.96f); + impactContainer.ScaleTo(SCALE_ADJUST); impactContainer.ScaleTo(1.12f, 250); } diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index 93c8faf0b0..2ee3bb30dd 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -85,15 +85,15 @@ namespace osu.Game.Screens.OnlinePlay.Components StarDifficulty minDifficulty; StarDifficulty maxDifficulty; - if (DifficultyRange.Value != null) + if (DifficultyRange.Value != null && Playlist.Count == 0) { + // When Playlist is empty (in lounge) we take retrieved range minDifficulty = new StarDifficulty(DifficultyRange.Value.Min, 0); maxDifficulty = new StarDifficulty(DifficultyRange.Value.Max, 0); } else { - // In multiplayer rooms, the beatmaps of playlist items will not be populated to a point this can be correct. - // Either populating them via BeatmapLookupCache or polling the API for the room's DifficultyRange will be required. + // When Playlist is not empty (in room) we compute actual range var orderedDifficulties = Playlist.Select(p => p.Beatmap).OrderBy(b => b.StarRating).ToArray(); minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0); diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs index f96d547747..35e0482f2b 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs @@ -3,39 +3,14 @@ #nullable disable -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class MatchTypePill : OnlinePlayComposite + public partial class MatchTypePill : OnlinePlayPill { - private OsuTextFlowContainer textFlow; - - public MatchTypePill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new PillContainer - { - Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - } - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -45,8 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void onMatchTypeChanged(ValueChangedEvent type) { - textFlow.Clear(); - textFlow.AddText(type.NewValue.GetLocalisableDescription()); + TextFlow.Text = type.NewValue.GetLocalisableDescription(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs new file mode 100644 index 0000000000..3e6d7a2e54 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.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.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public abstract partial class OnlinePlayPill : OnlinePlayComposite + { + protected PillContainer Pill { get; private set; } = null!; + protected OsuTextFlowContainer TextFlow { get; private set; } = null!; + protected virtual FontUsage Font => OsuFont.GetFont(size: 12); + + protected OnlinePlayPill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = Pill = new PillContainer + { + Child = TextFlow = new OsuTextFlowContainer(s => s.Font = Font) + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs index 81ba48d135..d1365c02f3 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs @@ -5,40 +5,16 @@ using System.Linq; using Humanizer; -using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { /// /// A pill that displays the playlist item count. /// - public partial class PlaylistCountPill : OnlinePlayComposite + public partial class PlaylistCountPill : OnlinePlayPill { - private OsuTextFlowContainer count; - - public PlaylistCountPill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new PillContainer - { - Child = count = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -55,10 +31,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components ? Playlist.Count(i => !i.Expired) : PlaylistItemStats.Value.CountActive; - count.Clear(); - count.AddText(activeItems.ToLocalisableString(), s => s.Font = s.Font.With(weight: FontWeight.Bold)); - count.AddText(" "); - count.AddText("Beatmap".ToQuantity(activeItems, ShowQuantityAs.None)); + TextFlow.Clear(); + TextFlow.AddText(activeItems.ToLocalisableString(), s => s.Font = s.Font.With(weight: FontWeight.Bold)); + TextFlow.AddText(" "); + TextFlow.AddText("Beatmap".ToQuantity(activeItems, ShowQuantityAs.None)); } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs index 0175418a96..208c11c155 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs @@ -3,39 +3,14 @@ #nullable disable -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Online.Multiplayer; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class QueueModePill : OnlinePlayComposite + public partial class QueueModePill : OnlinePlayPill { - private OsuTextFlowContainer textFlow; - - public QueueModePill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new PillContainer - { - Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - } - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -45,8 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void onQueueModeChanged(ValueChangedEvent mode) { - textFlow.Clear(); - textFlow.AddText(mode.NewValue.GetLocalisableDescription()); + TextFlow.Text = mode.NewValue.GetLocalisableDescription(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs index 5d67a18d1f..10f6e59260 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs @@ -5,58 +5,30 @@ using osu.Framework.Allocation; using osu.Framework.Extensions; -using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class RoomSpecialCategoryPill : OnlinePlayComposite + public partial class RoomSpecialCategoryPill : OnlinePlayPill { - private SpriteText text; - private PillContainer pill; - [Resolved] private OsuColour colours { get; set; } - public RoomSpecialCategoryPill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = pill = new PillContainer - { - Background = - { - Colour = colours.Pink, - Alpha = 1 - }, - Child = text = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), - Colour = Color4.Black - } - }; - } + protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); protected override void LoadComplete() { base.LoadComplete(); + Pill.Background.Alpha = 1; + TextFlow.Colour = Color4.Black; + Category.BindValueChanged(c => { - text.Text = c.NewValue.GetLocalisableDescription(); - - var backgroundColour = colours.ForRoomCategory(Category.Value); - if (backgroundColour != null) - pill.Background.Colour = backgroundColour.Value; + TextFlow.Text = c.NewValue.GetLocalisableDescription(); + Pill.Background.Colour = colours.ForRoomCategory(c.NewValue) ?? colours.Pink; }, true); } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs index 201314851e..ca9917ad00 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs @@ -8,43 +8,20 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; -using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { /// /// A pill that displays the room's current status. /// - public partial class RoomStatusPill : OnlinePlayComposite + public partial class RoomStatusPill : OnlinePlayPill { [Resolved] private OsuColour colours { get; set; } - private PillContainer pill; - private SpriteText statusText; - - public RoomStatusPill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = pill = new PillContainer - { - Child = statusText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), - Colour = Color4.Black - } - }; - } + protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); protected override void LoadComplete() { @@ -54,15 +31,17 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Status.BindValueChanged(_ => updateDisplay(), true); FinishTransforms(true); + + TextFlow.Colour = Colour4.Black; + Pill.Background.Alpha = 1; } private void updateDisplay() { RoomStatus status = getDisplayStatus(); - pill.Background.Alpha = 1; - pill.Background.FadeColour(status.GetAppropriateColour(colours), 100); - statusText.Text = status.Message; + Pill.Background.FadeColour(status.GetAppropriateColour(colours), 100); + TextFlow.Text = status.Message; } private RoomStatus getDisplayStatus() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 207b9c378b..66acd6d1b0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -73,11 +73,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private OsuSpriteText typeLabel = null!; private LoadingLayer loadingLayer = null!; - public void SelectBeatmap() - { - if (matchSubScreen.IsCurrentScreen()) - matchSubScreen.Push(new MultiplayerMatchSongSelect(matchSubScreen.Room)); - } + public void SelectBeatmap() => selectBeatmapButton.TriggerClick(); [Resolved] private MultiplayerMatchSubScreen matchSubScreen { get; set; } = null!; @@ -97,6 +93,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private IDisposable? applyingSettingsOperation; private Drawable playlistContainer = null!; private DrawableRoomPlaylist drawablePlaylist = null!; + private RoundedButton selectBeatmapButton = null!; public MatchSettings(Room room) { @@ -275,12 +272,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.X, Height = DrawableRoomPlaylistItem.HEIGHT }, - new RoundedButton + selectBeatmapButton = new RoundedButton { RelativeSizeAxes = Axes.X, Height = 40, Text = "Select beatmap", - Action = SelectBeatmap + Action = () => + { + if (matchSubScreen.IsCurrentScreen()) + matchSubScreen.Push(new MultiplayerMatchSongSelect(matchSubScreen.Room)); + } } } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 3d80248306..37b50b4863 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -132,7 +132,12 @@ namespace osu.Game.Screens.OnlinePlay this.ScaleTo(1, 250, Easing.OutSine); Debug.Assert(screenStack.CurrentScreen != null); - screenStack.CurrentScreen.OnResuming(e); + + // if a subscreen was pushed to the nested stack while the stack was not present, this path will proxy `OnResuming()` + // to the subscreen before `OnEntering()` can even be called for the subscreen, breaking ordering expectations. + // 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); } @@ -143,7 +148,12 @@ namespace osu.Game.Screens.OnlinePlay this.FadeOut(250); Debug.Assert(screenStack.CurrentScreen != null); - screenStack.CurrentScreen.OnSuspending(e); + + // if a subscreen was pushed to the nested stack while the stack was not present, this path will proxy `OnSuspending()` + // to the subscreen before `OnEntering()` can even be called for the subscreen, breaking ordering expectations. + // to work around this, do not proxy suspend to screens that haven't loaded yet. + if ((screenStack.CurrentScreen as Drawable)?.IsLoaded == true) + screenStack.CurrentScreen.OnSuspending(e); } public override bool OnExiting(ScreenExitEvent e) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 0c25a32259..b0e4585986 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -6,14 +6,12 @@ using System; using System.Diagnostics; using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Extensions; using osu.Game.Online.Rooms; using osu.Game.Rulesets; -using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; @@ -63,13 +61,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return new PlaylistsResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, true); } - protected override async Task PrepareScoreForResultsAsync(Score score) - { - await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - - Score.ScoreInfo.TotalScore = ScoreProcessor.ComputeScore(ScoringMode.Standardised, Score.ScoreInfo); - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index bc4cc2b00f..9c098794a6 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -233,7 +233,13 @@ namespace osu.Game.Screens /// protected virtual void LogoArriving(OsuLogo logo, bool resuming) { - ApplyLogoArrivingDefaults(logo); + logo.Action = null; + logo.FadeOut(300, Easing.OutQuint); + logo.Anchor = Anchor.TopLeft; + logo.Origin = Anchor.Centre; + logo.RelativePositionAxes = Axes.Both; + logo.Triangles = true; + logo.Ripple = true; } private void applyArrivingDefaults(bool isResuming) @@ -244,22 +250,6 @@ namespace osu.Game.Screens }, true); } - /// - /// Applies default animations to an arriving logo. - /// Todo: This should not exist. - /// - /// The logo to apply animations to. - public static void ApplyLogoArrivingDefaults(OsuLogo logo) - { - logo.Action = null; - logo.FadeOut(300, Easing.OutQuint); - logo.Anchor = Anchor.TopLeft; - logo.Origin = Anchor.Centre; - logo.RelativePositionAxes = Axes.Both; - logo.Triangles = true; - logo.Ripple = true; - } - private void onExitingLogo() { logo?.AppendAnimatingAction(() => LogoExiting(logo), false); diff --git a/osu.Game/Screens/Play/ArgonKeyCounter.cs b/osu.Game/Screens/Play/ArgonKeyCounter.cs new file mode 100644 index 0000000000..2d725898d8 --- /dev/null +++ b/osu.Game/Screens/Play/ArgonKeyCounter.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.Play +{ + public partial class ArgonKeyCounter : KeyCounter + { + private Circle inputIndicator = null!; + private OsuSpriteText keyNameText = null!; + private OsuSpriteText countText = null!; + + // These values were taken from Figma + private const float line_height = 3; + private const float name_font_size = 10; + private const float count_font_size = 14; + + // Make things look bigger without using Scale + private const float scale_factor = 1.5f; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonKeyCounter(InputTrigger trigger) + : base(trigger) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + inputIndicator = new Circle + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = line_height * scale_factor, + Alpha = 0.5f + }, + keyNameText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Position = new Vector2(0, -13) * scale_factor, + Font = OsuFont.Torus.With(size: name_font_size * scale_factor, weight: FontWeight.Bold), + Colour = colours.Blue0, + Text = Trigger.Name + }, + countText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Torus.With(size: count_font_size * scale_factor, weight: FontWeight.Bold), + }, + }; + + // Values from Figma didn't match visually + // So these were just eyeballed + Height = 30 * scale_factor; + Width = 35 * scale_factor; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + CountPresses.BindValueChanged(e => countText.Text = e.NewValue.ToString(@"#,0"), true); + } + + protected override void Activate(bool forwardPlayback = true) + { + base.Activate(forwardPlayback); + + keyNameText + .FadeColour(Colour4.White, 10, Easing.OutQuint); + + inputIndicator + .FadeIn(10, Easing.OutQuint) + .MoveToY(0) + .Then() + .MoveToY(4, 60, Easing.OutQuint); + } + + protected override void Deactivate(bool forwardPlayback = true) + { + base.Deactivate(forwardPlayback); + + keyNameText + .FadeColour(colours.Blue0, 200, Easing.OutQuart); + + inputIndicator + .MoveToY(0, 250, Easing.OutQuart) + .FadeTo(0.5f, 250, Easing.OutQuart); + } + } +} diff --git a/osu.Game/Screens/Play/ArgonKeyCounterDisplay.cs b/osu.Game/Screens/Play/ArgonKeyCounterDisplay.cs new file mode 100644 index 0000000000..984c2a7287 --- /dev/null +++ b/osu.Game/Screens/Play/ArgonKeyCounterDisplay.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.Play +{ + public partial class ArgonKeyCounterDisplay : KeyCounterDisplay + { + private const int duration = 100; + + protected override FillFlowContainer KeyFlow { get; } + + public ArgonKeyCounterDisplay() + { + InternalChild = KeyFlow = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Alpha = 0, + Spacing = new Vector2(2), + }; + } + + protected override void Update() + { + base.Update(); + + Size = KeyFlow.Size; + } + + protected override KeyCounter CreateCounter(InputTrigger trigger) => new ArgonKeyCounter(trigger); + + protected override void UpdateVisibility() + => KeyFlow.FadeTo(AlwaysVisible.Value || ConfigVisibility.Value ? 1 : 0, duration); + } +} diff --git a/osu.Game/Screens/Play/Break/BlurredIcon.cs b/osu.Game/Screens/Play/Break/BlurredIcon.cs index cd38390324..6ce1c2e686 100644 --- a/osu.Game/Screens/Play/Break/BlurredIcon.cs +++ b/osu.Game/Screens/Play/Break/BlurredIcon.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Play.Break set { icon.Size = value; - base.Size = value + BlurSigma * 2.5f; + base.Size = value + BlurSigma * 5; ForceRedraw(); } get => base.Size; diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 0214d33549..57bdad079e 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -1,15 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Audio; -using osu.Framework.Bindables; -using osu.Game.Rulesets.UI; using System; using System.Collections.Generic; using ManagedBass.Fx; using osu.Framework.Allocation; -using osu.Framework.Audio.Sample; +using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -21,6 +19,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -50,8 +49,7 @@ namespace osu.Game.Screens.Play private const float duration = 2500; - private ISample? failSample; - private SampleChannel? failSampleChannel; + private SkinnableSound failSample = null!; [Resolved] private OsuConfigManager config { get; set; } = null!; @@ -76,10 +74,10 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader] - private void load(AudioManager audio, ISkinSource skin, IBindable beatmap) + private void load(AudioManager audio, IBindable beatmap) { track = beatmap.Value.Track; - failSample = skin.GetSample(new SampleInfo(@"Gameplay/failsound")); + AddInternal(failSample = new SkinnableSound(new SampleInfo("Gameplay/failsound"))); AddRangeInternal(new Drawable[] { @@ -126,7 +124,7 @@ namespace osu.Game.Screens.Play failHighPassFilter.CutoffTo(300); failLowPassFilter.CutoffTo(300, duration, Easing.OutCubic); - failSampleChannel = failSample?.Play(); + failSample.Play(); track.AddAdjustment(AdjustableProperty.Frequency, trackFreq); track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); @@ -159,7 +157,7 @@ namespace osu.Game.Screens.Play /// public void Stop() { - failSampleChannel?.Stop(); + failSample.Stop(); removeFilters(); } diff --git a/osu.Game/Screens/Play/FailOverlay.cs b/osu.Game/Screens/Play/FailOverlay.cs index 4fbc937b59..abfc401998 100644 --- a/osu.Game/Screens/Play/FailOverlay.cs +++ b/osu.Game/Screens/Play/FailOverlay.cs @@ -15,6 +15,8 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Screens.Play { @@ -22,14 +24,13 @@ namespace osu.Game.Screens.Play { public Func> SaveReplay; - public override string Header => "failed"; - public override string Description => "you're dead, try again?"; + public override LocalisableString Header => GameplayMenuOverlayStrings.FailedHeader; [BackgroundDependencyLoader] private void load(OsuColour colours) { - AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); - AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Retry, colours.YellowDark, () => OnRetry?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Quit, new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); // from #10339 maybe this is a better visual effect Add(new Container { diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 81146a4ea6..0680842891 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.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 Humanizer; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -15,6 +12,8 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; 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; @@ -22,6 +21,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Screens.Play { @@ -38,8 +38,8 @@ namespace osu.Game.Screens.Play public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public Action OnRetry; - public Action OnQuit; + public Action? OnRetry; + public Action? OnQuit; /// /// Action that is invoked when is triggered. @@ -51,14 +51,15 @@ namespace osu.Game.Screens.Play /// protected virtual Action SelectAction => () => InternalButtons.Selected?.TriggerClick(); - public abstract string Header { get; } + public abstract LocalisableString Header { get; } - public abstract string Description { get; } - - protected SelectionCycleFillFlowContainer InternalButtons; + protected SelectionCycleFillFlowContainer InternalButtons = null!; public IReadOnlyList Buttons => InternalButtons; - private FillFlowContainer retryCounterContainer; + private TextFlowContainer playInfoText = null!; + + [Resolved] + private GlobalActionContainer globalAction { get; set; } = null!; protected GameplayMenuOverlay() { @@ -86,36 +87,13 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Children = new Drawable[] { - new FillFlowContainer + new OsuSpriteText { + Text = Header, + Font = OsuFont.GetFont(size: 48), Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Children = new Drawable[] - { - new OsuSpriteText - { - Text = Header, - Font = OsuFont.GetFont(size: 30), - Spacing = new Vector2(5, 0), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Colour = colours.Yellow, - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f) - }, - new OsuSpriteText - { - Text = Description, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f) - } - } + Colour = colours.Yellow, }, InternalButtons = new SelectionCycleFillFlowContainer { @@ -132,10 +110,11 @@ namespace osu.Game.Screens.Play Radius = 50 }, }, - retryCounterContainer = new FillFlowContainer + playInfoText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.GetFont(size: 18)) { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, AutoSizeAxes = Axes.Both, } } @@ -144,7 +123,7 @@ namespace osu.Game.Screens.Play State.ValueChanged += _ => InternalButtons.Deselect(); - updateRetryCount(); + updateInfoText(); } private int retries; @@ -157,12 +136,18 @@ namespace osu.Game.Screens.Play return; retries = value; - if (retryCounterContainer != null) - updateRetryCount(); + + if (IsLoaded) + updateInfoText(); } } - protected override void PopIn() => this.FadeIn(TRANSITION_DURATION, Easing.In); + protected override void PopIn() + { + this.FadeIn(TRANSITION_DURATION, Easing.In); + updateInfoText(); + } + 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. @@ -170,7 +155,7 @@ namespace osu.Game.Screens.Play protected override bool OnMouseMove(MouseMoveEvent e) => true; - protected void AddButton(string text, Color4 colour, Action action) + protected void AddButton(LocalisableString text, Color4 colour, Action? action) { var button = new Button { @@ -217,35 +202,39 @@ namespace osu.Game.Screens.Play { } - private void updateRetryCount() - { - // "You've retried 1,065 times in this session" - // "You've retried 1 time in this session" + [Resolved] + private IGameplayClock? gameplayClock { get; set; } - retryCounterContainer.Children = new Drawable[] + [Resolved] + private GameplayState? gameplayState { get; set; } + + private void updateInfoText() + { + playInfoText.Clear(); + playInfoText.AddText(GameplayMenuOverlayStrings.RetryCount); + playInfoText.AddText(retries.ToString(), cp => cp.Font = cp.Font.With(weight: FontWeight.Bold)); + + if (getSongProgress() is int progress) { - new OsuSpriteText - { - Text = "You've retried ", - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f), - Font = OsuFont.GetFont(size: 18), - }, - new OsuSpriteText - { - Text = "time".ToQuantity(retries), - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f), - }, - new OsuSpriteText - { - Text = " in this session", - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f), - Font = OsuFont.GetFont(size: 18), - } - }; + playInfoText.NewLine(); + playInfoText.AddText(GameplayMenuOverlayStrings.SongProgress); + playInfoText.AddText($"{progress}%", cp => cp.Font = cp.Font.With(weight: FontWeight.Bold)); + } + } + + private int? getSongProgress() + { + if (gameplayClock == null || gameplayState == null) + return null; + + (double firstHitTime, double lastHitTime) = gameplayState.Beatmap.CalculatePlayableBounds(); + + double playableLength = (lastHitTime - firstHitTime); + + if (playableLength == 0) + return 0; + + return (int)Math.Clamp(((gameplayClock.CurrentTime - firstHitTime) / playableLength) * 100, 0, 100); } private partial class Button : DialogButton @@ -260,9 +249,6 @@ namespace osu.Game.Screens.Play } } - [Resolved] - private GlobalActionContainer globalAction { get; set; } - protected override bool Handle(UIEvent e) { switch (e) diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs index 0899476ed4..63ab9d15e0 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Graphics.UserInterface; @@ -26,8 +27,7 @@ namespace osu.Game.Screens.Play.HUD if (!objects.Any()) return; - double firstHit = objects.First().StartTime; - double lastHit = objects.Max(o => o.GetEndTime()); + (double firstHit, double lastHit) = BeatmapExtensions.CalculatePlayableBounds(objects); if (lastHit == 0) lastHit = objects.Last().StartTime; diff --git a/osu.Game/Screens/Play/KeyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultKeyCounter.cs similarity index 69% rename from osu.Game/Screens/Play/KeyCounter.cs rename to osu.Game/Screens/Play/HUD/DefaultKeyCounter.cs index 4405542b3b..f7ac72035f 100644 --- a/osu.Game/Screens/Play/KeyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultKeyCounter.cs @@ -1,8 +1,6 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,70 +11,23 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public abstract partial class KeyCounter : Container + public partial class DefaultKeyCounter : KeyCounter { - private Sprite buttonSprite; - private Sprite glowSprite; - private Container textLayer; - private SpriteText countSpriteText; - - public bool IsCounting { get; set; } = true; - private int countPresses; - - public int CountPresses - { - get => countPresses; - private set - { - if (countPresses != value) - { - countPresses = value; - countSpriteText.Text = value.ToString(@"#,0"); - } - } - } - - private bool isLit; - - public bool IsLit - { - get => isLit; - protected set - { - if (isLit != value) - { - isLit = value; - updateGlowSprite(value); - } - } - } - - public void Increment() - { - if (!IsCounting) - return; - - CountPresses++; - } - - public void Decrement() - { - if (!IsCounting) - return; - - CountPresses--; - } + private Sprite buttonSprite = null!; + private Sprite glowSprite = null!; + private Container textLayer = null!; + private SpriteText countSpriteText = null!; //further: change default values here and in KeyCounterCollection if needed, instead of passing them in every constructor public Color4 KeyDownTextColor { get; set; } = Color4.DarkGray; public Color4 KeyUpTextColor { get; set; } = Color4.White; public double FadeTime { get; set; } - protected KeyCounter(string name) + public DefaultKeyCounter(InputTrigger trigger) + : base(trigger) { - Name = name; } [BackgroundDependencyLoader(true)] @@ -106,7 +57,7 @@ namespace osu.Game.Screens.Play { new OsuSpriteText { - Text = Name, + Text = Trigger.Name, Font = OsuFont.Numeric.With(size: 12), Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -116,7 +67,7 @@ namespace osu.Game.Screens.Play }, countSpriteText = new OsuSpriteText { - Text = CountPresses.ToString(@"#,0"), + Text = CountPresses.Value.ToString(@"#,0"), Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativePositionAxes = Axes.Both, @@ -130,6 +81,9 @@ namespace osu.Game.Screens.Play // so the size can be changing between buttonSprite and glowSprite. Height = buttonSprite.DrawHeight; Width = buttonSprite.DrawWidth; + + IsActive.BindValueChanged(e => updateGlowSprite(e.NewValue), true); + CountPresses.BindValueChanged(e => countSpriteText.Text = e.NewValue.ToString(@"#,0"), true); } private void updateGlowSprite(bool show) diff --git a/osu.Game/Screens/Play/HUD/DefaultKeyCounterDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultKeyCounterDisplay.cs new file mode 100644 index 0000000000..e459574243 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultKeyCounterDisplay.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class DefaultKeyCounterDisplay : KeyCounterDisplay + { + private const int duration = 100; + private const double key_fade_time = 80; + + protected override FillFlowContainer KeyFlow { get; } + + public DefaultKeyCounterDisplay() + { + InternalChild = KeyFlow = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Alpha = 0, + }; + } + + protected override void Update() + { + base.Update(); + + // Don't use autosize as it will shrink to zero when KeyFlow is hidden. + // In turn this can cause the display to be masked off screen and never become visible again. + Size = KeyFlow.Size; + } + + protected override KeyCounter CreateCounter(InputTrigger trigger) => new DefaultKeyCounter(trigger) + { + FadeTime = key_fade_time, + KeyDownTextColor = KeyDownTextColor, + KeyUpTextColor = KeyUpTextColor, + }; + + protected override void UpdateVisibility() => + // Isolate changing visibility of the key counters from fading this component. + KeyFlow.FadeTo(AlwaysVisible.Value || ConfigVisibility.Value ? 1 : 0, duration); + + private Color4 keyDownTextColor = Color4.DarkGray; + + public Color4 KeyDownTextColor + { + get => keyDownTextColor; + set + { + if (value != keyDownTextColor) + { + keyDownTextColor = value; + foreach (var child in KeyFlow.Cast()) + child.KeyDownTextColor = value; + } + } + } + + private Color4 keyUpTextColor = Color4.White; + + public Color4 KeyUpTextColor + { + get => keyUpTextColor; + set + { + if (value != keyUpTextColor) + { + keyUpTextColor = value; + foreach (var child in KeyFlow.Cast()) + child.KeyUpTextColor = value; + } + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs index bee5978817..047c64a4a4 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Collections.Generic; using System.Diagnostics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Play.HUD @@ -26,8 +27,7 @@ namespace osu.Game.Screens.Play.HUD if (!objects.Any()) return; - double firstHit = objects.First().StartTime; - double lastHit = objects.Max(o => o.GetEndTime()); + (double firstHit, double lastHit) = BeatmapExtensions.CalculatePlayableBounds(objects); if (lastHit == 0) lastHit = objects.Last().StartTime; diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 07b80feb3e..4ac2f1afda 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -1,18 +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; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; @@ -48,7 +47,7 @@ namespace osu.Game.Screens.Play.HUD public Bindable Expanded = new Bindable(); - private OsuSpriteText positionText, scoreText, accuracyText, comboText, usernameText; + 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); @@ -56,6 +55,13 @@ namespace osu.Game.Screens.Play.HUD 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; } @@ -82,40 +88,43 @@ namespace osu.Game.Screens.Play.HUD } } - [CanBeNull] - public IUser User { 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 Container mainFillContainer; + private Container mainFillContainer = null!; - private Box centralFill; + private Box centralFill = null!; - private Container backgroundPaddingAdjustContainer; + private Container backgroundPaddingAdjustContainer = null!; - private GridContainer gridContainer; + private GridContainer gridContainer = null!; - private Container scoreComponents; + private Container scoreComponents = null!; + + private IBindable scoreDisplayMode = null!; /// /// Creates a new . /// /// The score's player. /// Whether the player is the local user or a replay player. - public GameplayLeaderboardScore([CanBeNull] IUser user, bool tracked) + 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) + private void load(OsuColour colours, OsuConfigManager osuConfigManager) { Container avatarContainer; @@ -234,7 +243,7 @@ namespace osu.Game.Screens.Play.HUD Origin = Anchor.CentreLeft, Colour = Color4.White, Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - Text = User?.Username, + Text = User?.Username ?? string.Empty, Truncate = true, Shadow = false, } @@ -286,7 +295,9 @@ namespace osu.Game.Screens.Play.HUD LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add); - TotalScore.BindValueChanged(v => scoreText.Text = v.NewValue.ToString("N0"), true); + scoreDisplayMode = osuConfigManager.GetBindable(OsuSetting.ScoreDisplayMode); + scoreDisplayMode.BindValueChanged(_ => updateScore()); + TotalScore.BindValueChanged(_ => updateScore(), true); Accuracy.BindValueChanged(v => { @@ -313,6 +324,8 @@ namespace osu.Game.Screens.Play.HUD FinishTransforms(true); } + private void updateScore() => scoreText.Text = (getDisplayScoreFunction?.Invoke(scoreDisplayMode.Value) ?? TotalScore.Value).ToString("N0"); + private void changeExpandedState(ValueChangedEvent expanded) { if (expanded.NewValue) diff --git a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs index a11cccd97c..a086aa6d72 100644 --- a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs @@ -1,20 +1,21 @@ // 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.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring.Legacy; namespace osu.Game.Screens.Play.HUD { public abstract partial class GameplayScoreCounter : ScoreCounter { - private Bindable scoreDisplayMode; + private Bindable scoreDisplayMode = null!; + + private Bindable totalScoreBindable = null!; protected GameplayScoreCounter() : base(6) @@ -24,6 +25,9 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(OsuConfigManager config, ScoreProcessor scoreProcessor) { + totalScoreBindable = scoreProcessor.TotalScore.GetBoundCopy(); + totalScoreBindable.BindValueChanged(_ => updateDisplayScore()); + scoreDisplayMode = config.GetBindable(OsuSetting.ScoreDisplayMode); scoreDisplayMode.BindValueChanged(scoreMode => { @@ -40,9 +44,11 @@ namespace osu.Game.Screens.Play.HUD default: throw new ArgumentOutOfRangeException(nameof(scoreMode)); } + + updateDisplayScore(); }, true); - Current.BindTo(scoreProcessor.TotalScore); + void updateDisplayScore() => Current.Value = scoreProcessor.GetDisplayScore(scoreDisplayMode.Value); } } } diff --git a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs index 428390f90c..1a5d7fd9a8 100644 --- a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/ILeaderboardScore.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. -#nullable disable - +using System; using osu.Framework.Bindables; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Play.HUD { @@ -20,5 +20,12 @@ namespace osu.Game.Screens.Play.HUD /// 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/InputTrigger.cs b/osu.Game/Screens/Play/HUD/InputTrigger.cs new file mode 100644 index 0000000000..b57f2cdf91 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/InputTrigger.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// An event trigger which can be used with to create visual tracking of button/key presses. + /// + public abstract partial class InputTrigger : Component + { + /// + /// Callback to invoke when the associated input has been activated. + /// + /// Whether gameplay is progressing in the forward direction time-wise. + public delegate void OnActivateCallback(bool forwardPlayback); + + /// + /// Callback to invoke when the associated input has been deactivated. + /// + /// Whether gameplay is progressing in the forward direction time-wise. + public delegate void OnDeactivateCallback(bool forwardPlayback); + + public event OnActivateCallback? OnActivate; + public event OnDeactivateCallback? OnDeactivate; + + protected InputTrigger(string name) + { + Name = name; + } + + protected void Activate(bool forwardPlayback = true) => OnActivate?.Invoke(forwardPlayback); + + protected void Deactivate(bool forwardPlayback = true) => OnDeactivate?.Invoke(forwardPlayback); + } +} diff --git a/osu.Game/Screens/Play/HUD/KeyCounter.cs b/osu.Game/Screens/Play/HUD/KeyCounter.cs new file mode 100644 index 0000000000..7cdd6b025f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/KeyCounter.cs @@ -0,0 +1,96 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// An individual key display which is intended to be displayed within a . + /// + public abstract partial class KeyCounter : Container + { + /// + /// The which activates and deactivates this . + /// + public readonly InputTrigger Trigger; + + /// + /// Whether the actions reported by should be counted. + /// + public Bindable IsCounting { get; } = new BindableBool(true); + + private readonly Bindable countPresses = new BindableInt + { + MinValue = 0 + }; + + /// + /// The current count of registered key presses. + /// + public IBindable CountPresses => countPresses; + + private readonly Container content; + + protected override Container Content => content; + + /// + /// Whether this is currently in the "activated" state because the associated key is currently pressed. + /// + protected readonly Bindable IsActive = new BindableBool(); + + protected KeyCounter(InputTrigger trigger) + { + InternalChildren = new Drawable[] + { + content = new Container + { + RelativeSizeAxes = Axes.Both + }, + Trigger = trigger, + }; + + Trigger.OnActivate += Activate; + Trigger.OnDeactivate += Deactivate; + } + + private void increment() + { + if (!IsCounting.Value) + return; + + countPresses.Value++; + } + + private void decrement() + { + if (!IsCounting.Value) + return; + + countPresses.Value--; + } + + protected virtual void Activate(bool forwardPlayback = true) + { + IsActive.Value = true; + if (forwardPlayback) + increment(); + } + + protected virtual void Deactivate(bool forwardPlayback = true) + { + IsActive.Value = false; + if (!forwardPlayback) + decrement(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + Trigger.OnActivate -= Activate; + Trigger.OnDeactivate -= Deactivate; + } + } +} diff --git a/osu.Game/Screens/Play/KeyCounterAction.cs b/osu.Game/Screens/Play/HUD/KeyCounterActionTrigger.cs similarity index 70% rename from osu.Game/Screens/Play/KeyCounterAction.cs rename to osu.Game/Screens/Play/HUD/KeyCounterActionTrigger.cs index 900d9bcd0e..e5951a8bf4 100644 --- a/osu.Game/Screens/Play/KeyCounterAction.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounterActionTrigger.cs @@ -1,18 +1,16 @@ // 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; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public partial class KeyCounterAction : KeyCounter + public partial class KeyCounterActionTrigger : InputTrigger where T : struct { public T Action { get; } - public KeyCounterAction(T action) + public KeyCounterActionTrigger(T action) : base($"B{(int)(object)action + 1}") { Action = action; @@ -23,9 +21,7 @@ namespace osu.Game.Screens.Play if (!EqualityComparer.Default.Equals(action, Action)) return false; - IsLit = true; - if (forwards) - Increment(); + Activate(forwards); return false; } @@ -34,9 +30,7 @@ namespace osu.Game.Screens.Play if (!EqualityComparer.Default.Equals(action, Action)) return; - IsLit = false; - if (!forwards) - Decrement(); + Deactivate(forwards); } } } diff --git a/osu.Game/Screens/Play/HUD/KeyCounterDisplay.cs b/osu.Game/Screens/Play/HUD/KeyCounterDisplay.cs new file mode 100644 index 0000000000..05427d3a32 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/KeyCounterDisplay.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; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// A flowing display of all gameplay keys. Individual keys can be added using implementations. + /// + public abstract partial class KeyCounterDisplay : CompositeDrawable + { + /// + /// Whether the key counter should be visible regardless of the configuration value. + /// This is true by default, but can be changed. + /// + public Bindable AlwaysVisible { get; } = new Bindable(true); + + /// + /// The s contained in this . + /// + public IEnumerable Counters => KeyFlow; + + protected abstract FillFlowContainer KeyFlow { get; } + + /// + /// Whether the actions reported by all s within this should be counted. + /// + public Bindable IsCounting { get; } = new BindableBool(true); + + protected readonly Bindable ConfigVisibility = new Bindable(); + + protected abstract void UpdateVisibility(); + + private Receptor? receptor; + + public void SetReceptor(Receptor receptor) + { + if (this.receptor != null) + throw new InvalidOperationException("Cannot set a new receptor when one is already active"); + + this.receptor = receptor; + } + + /// + /// Add a to this display. + /// + public void Add(InputTrigger trigger) + { + var keyCounter = CreateCounter(trigger); + + KeyFlow.Add(keyCounter); + + IsCounting.BindTo(keyCounter.IsCounting); + } + + /// + /// Add a range of to this display. + /// + public void AddRange(IEnumerable triggers) => triggers.ForEach(Add); + + protected abstract KeyCounter CreateCounter(InputTrigger trigger); + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.KeyOverlay, ConfigVisibility); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AlwaysVisible.BindValueChanged(_ => UpdateVisibility()); + ConfigVisibility.BindValueChanged(_ => UpdateVisibility(), true); + } + + public override bool HandleNonPositionalInput => receptor == null; + + public override bool HandlePositionalInput => receptor == null; + + public partial class Receptor : Drawable + { + protected readonly KeyCounterDisplay Target; + + public Receptor(KeyCounterDisplay target) + { + RelativeSizeAxes = Axes.Both; + Depth = float.MinValue; + Target = target; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + protected override bool Handle(UIEvent e) + { + switch (e) + { + case KeyDownEvent: + case KeyUpEvent: + case MouseDownEvent: + case MouseUpEvent: + return Target.InternalChildren.Any(c => c.TriggerEvent(e)); + } + + return base.Handle(e); + } + } + } +} diff --git a/osu.Game/Screens/Play/KeyCounterKeyboard.cs b/osu.Game/Screens/Play/HUD/KeyCounterKeyboardTrigger.cs similarity index 70% rename from osu.Game/Screens/Play/KeyCounterKeyboard.cs rename to osu.Game/Screens/Play/HUD/KeyCounterKeyboardTrigger.cs index c5c8b7eeae..3052c1e666 100644 --- a/osu.Game/Screens/Play/KeyCounterKeyboard.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounterKeyboardTrigger.cs @@ -1,18 +1,16 @@ // 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.Input.Events; using osuTK.Input; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public partial class KeyCounterKeyboard : KeyCounter + public partial class KeyCounterKeyboardTrigger : InputTrigger { public Key Key { get; } - public KeyCounterKeyboard(Key key) + public KeyCounterKeyboardTrigger(Key key) : base(key.ToString()) { Key = key; @@ -22,8 +20,7 @@ namespace osu.Game.Screens.Play { if (e.Key == Key) { - IsLit = true; - Increment(); + Activate(); } return base.OnKeyDown(e); @@ -31,7 +28,9 @@ namespace osu.Game.Screens.Play protected override void OnKeyUp(KeyUpEvent e) { - if (e.Key == Key) IsLit = false; + if (e.Key == Key) + Deactivate(); + base.OnKeyUp(e); } } diff --git a/osu.Game/Screens/Play/KeyCounterMouse.cs b/osu.Game/Screens/Play/HUD/KeyCounterMouseTrigger.cs similarity index 79% rename from osu.Game/Screens/Play/KeyCounterMouse.cs rename to osu.Game/Screens/Play/HUD/KeyCounterMouseTrigger.cs index cf9c7c029f..369aaa9f74 100644 --- a/osu.Game/Screens/Play/KeyCounterMouse.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounterMouseTrigger.cs @@ -1,19 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Input.Events; -using osuTK.Input; using osuTK; +using osuTK.Input; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public partial class KeyCounterMouse : KeyCounter + public partial class KeyCounterMouseTrigger : InputTrigger { public MouseButton Button { get; } - public KeyCounterMouse(MouseButton button) + public KeyCounterMouseTrigger(MouseButton button) : base(getStringRepresentation(button)) { Button = button; @@ -39,17 +37,16 @@ namespace osu.Game.Screens.Play protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button == Button) - { - IsLit = true; - Increment(); - } + Activate(); return base.OnMouseDown(e); } protected override void OnMouseUp(MouseUpEvent e) { - if (e.Button == Button) IsLit = false; + if (e.Button == Button) + Deactivate(); + base.OnMouseUp(e); } } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 620f3718c2..922def6174 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -98,6 +98,7 @@ namespace osu.Game.Screens.Play.HUD 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); diff --git a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs index 9f92880919..e9bb1d2101 100644 --- a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.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.Bindables; @@ -10,6 +9,7 @@ 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; @@ -27,15 +27,9 @@ namespace osu.Game.Screens.Play.HUD public readonly IBindableList Scores = new BindableList(); - // hold references to ensure bindables are updated. - private readonly List> scoreBindables = new List>(); - [Resolved] private ScoreProcessor scoreProcessor { get; set; } = null!; - [Resolved] - private ScoreManager scoreManager { get; set; } = null!; - /// /// Whether the leaderboard should be visible regardless of the configuration value. /// This is true by default, but can be changed. @@ -70,7 +64,6 @@ namespace osu.Game.Screens.Play.HUD private void showScores() { Clear(); - scoreBindables.Clear(); if (!Scores.Any()) return; @@ -79,12 +72,8 @@ namespace osu.Game.Screens.Play.HUD { var score = Add(s.User, false); - var bindableTotal = scoreManager.GetBindableTotalScore(s); - - // Direct binding not possible due to differing types (see https://github.com/ppy/osu/issues/20298). - bindableTotal.BindValueChanged(total => score.TotalScore.Value = total.NewValue, true); - scoreBindables.Add(bindableTotal); - + 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(); @@ -92,6 +81,7 @@ namespace osu.Game.Screens.Play.HUD ILeaderboardScore local = Add(trackingUser, true); + local.GetDisplayScore = scoreProcessor.GetDisplayScore; local.TotalScore.BindTarget = scoreProcessor.TotalScore; local.Accuracy.BindTarget = scoreProcessor.Accuracy; local.Combo.BindTarget = scoreProcessor.HighestCombo; diff --git a/osu.Game/Screens/Play/HUD/SongProgress.cs b/osu.Game/Screens/Play/HUD/SongProgress.cs index ebe2fb83e6..4391193df8 100644 --- a/osu.Game/Screens/Play/HUD/SongProgress.cs +++ b/osu.Game/Screens/Play/HUD/SongProgress.cs @@ -2,12 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Skinning; @@ -52,9 +52,9 @@ namespace osu.Game.Screens.Play.HUD set { objects = value; - FirstHitTime = objects.FirstOrDefault()?.StartTime ?? 0; - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - LastHitTime = objects.LastOrDefault()?.GetEndTime() ?? 0; + + (FirstHitTime, LastHitTime) = BeatmapExtensions.CalculatePlayableBounds(objects); + UpdateObjects(objects); } } diff --git a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs index c04ecd671f..2f137f7e78 100644 --- a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs +++ b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs @@ -145,12 +145,12 @@ namespace osu.Game.Screens.Play.HUD double time = gameplayClock?.CurrentTime ?? Time.Current; double songCurrentTime = time - startTime; - int currentPercent = Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); + int currentPercent = songLength == 0 ? 0 : Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); int currentSecond = (int)Math.Floor(songCurrentTime / 1000.0); if (currentPercent != previousPercent) { - progress.Text = currentPercent + @"%"; + progress.Text = $@"{currentPercent}%"; previousPercent = currentPercent; } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index c8d06b82e8..9f050a07bd 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -331,7 +331,7 @@ namespace osu.Game.Screens.Play ShowHealth = { BindTarget = ShowHealthBar } }; - protected KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay + protected KeyCounterDisplay CreateKeyCounter() => new DefaultKeyCounterDisplay { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, diff --git a/osu.Game/Screens/Play/KeyCounterDisplay.cs b/osu.Game/Screens/Play/KeyCounterDisplay.cs deleted file mode 100644 index bb50d4a539..0000000000 --- a/osu.Game/Screens/Play/KeyCounterDisplay.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. - -#nullable disable - -using System; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; -using osu.Game.Configuration; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Play -{ - public partial class KeyCounterDisplay : Container - { - private const int duration = 100; - private const double key_fade_time = 80; - - private readonly Bindable configVisibility = new Bindable(); - - protected readonly FillFlowContainer KeyFlow; - - protected override Container Content => KeyFlow; - - /// - /// Whether the key counter 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 KeyCounterDisplay() - { - InternalChild = KeyFlow = new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Alpha = 0, - }; - } - - protected override void Update() - { - base.Update(); - - // Don't use autosize as it will shrink to zero when KeyFlow is hidden. - // In turn this can cause the display to be masked off screen and never become visible again. - Size = KeyFlow.Size; - } - - public override void Add(KeyCounter key) - { - ArgumentNullException.ThrowIfNull(key); - - base.Add(key); - key.IsCounting = IsCounting; - key.FadeTime = key_fade_time; - key.KeyDownTextColor = KeyDownTextColor; - key.KeyUpTextColor = KeyUpTextColor; - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - config.BindWith(OsuSetting.KeyOverlay, configVisibility); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - AlwaysVisible.BindValueChanged(_ => updateVisibility()); - configVisibility.BindValueChanged(_ => updateVisibility(), true); - } - - private bool isCounting = true; - - public bool IsCounting - { - get => isCounting; - set - { - if (value == isCounting) return; - - isCounting = value; - foreach (var child in Children) - child.IsCounting = value; - } - } - - private Color4 keyDownTextColor = Color4.DarkGray; - - public Color4 KeyDownTextColor - { - get => keyDownTextColor; - set - { - if (value != keyDownTextColor) - { - keyDownTextColor = value; - foreach (var child in Children) - child.KeyDownTextColor = value; - } - } - } - - private Color4 keyUpTextColor = Color4.White; - - public Color4 KeyUpTextColor - { - get => keyUpTextColor; - set - { - if (value != keyUpTextColor) - { - keyUpTextColor = value; - foreach (var child in Children) - child.KeyUpTextColor = value; - } - } - } - - private void updateVisibility() => - // Isolate changing visibility of the key counters from fading this component. - KeyFlow.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration); - - public override bool HandleNonPositionalInput => receptor == null; - public override bool HandlePositionalInput => receptor == null; - - private Receptor receptor; - - public void SetReceptor(Receptor receptor) - { - if (this.receptor != null) - throw new InvalidOperationException("Cannot set a new receptor when one is already active"); - - this.receptor = receptor; - } - - public partial class Receptor : Drawable - { - protected readonly KeyCounterDisplay Target; - - public Receptor(KeyCounterDisplay target) - { - RelativeSizeAxes = Axes.Both; - Depth = float.MinValue; - Target = target; - } - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - - protected override bool Handle(UIEvent e) - { - switch (e) - { - case KeyDownEvent: - case KeyUpEvent: - case MouseDownEvent: - case MouseUpEvent: - return Target.Children.Any(c => c.TriggerEvent(e)); - } - - return base.Handle(e); - } - } - } -} diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index db42998c45..88561ada71 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -9,9 +9,11 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Skinning; using osuTK.Graphics; @@ -23,8 +25,7 @@ namespace osu.Game.Screens.Play public override bool IsPresent => base.IsPresent || pauseLoop.IsPlaying; - public override string Header => "paused"; - public override string Description => "you're not going to do what i think you're going to do, are ya?"; + public override LocalisableString Header => GameplayMenuOverlayStrings.PausedHeader; private SkinnableSound pauseLoop; @@ -33,9 +34,9 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader] private void load(OsuColour colours) { - AddButton("Continue", colours.Green, () => OnResume?.Invoke()); - AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); - AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Continue, colours.Green, () => OnResume?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Retry, colours.YellowDark, () => OnRetry?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Quit, new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); AddInternal(pauseLoop = new SkinnableSound(new SampleInfo("Gameplay/pause-loop")) { diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 45a671fb89..18ea9d0acb 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -237,9 +237,6 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(HealthProcessor); - if (!ScoreProcessor.Mode.Disabled) - config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode); - InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); @@ -358,14 +355,10 @@ namespace osu.Game.Screens.Play ScoreProcessor.RevertResult(r); }; - DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded => - { - if (storyboardEnded.NewValue) - progressToResults(true); - }; + DimmableStoryboard.HasStoryboardEnded.ValueChanged += _ => checkScoreCompleted(); // Bind the judgement processors to ourselves - ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged); + ScoreProcessor.HasCompleted.BindValueChanged(_ => checkScoreCompleted()); HealthProcessor.Failed += onFail; // Provide judgement processors to mods after they're loaded so that they're on the gameplay clock, @@ -441,8 +434,11 @@ namespace osu.Game.Screens.Play }, KeyCounter = { + IsCounting = + { + Value = false + }, AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, - IsCounting = false }, Anchor = Anchor.Centre, Origin = Anchor.Centre @@ -482,7 +478,7 @@ namespace osu.Game.Screens.Play { updateGameplayState(); updatePauseOnFocusLostState(); - HUDOverlay.KeyCounter.IsCounting = !isBreakTime.NewValue; + HUDOverlay.KeyCounter.IsCounting.Value = !isBreakTime.NewValue; } private void updateGameplayState() @@ -706,19 +702,20 @@ namespace osu.Game.Screens.Play /// /// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime. /// - /// Thrown if this method is called more than once without changing state. - private void scoreCompletionChanged(ValueChangedEvent completed) + private void checkScoreCompleted() { // If this player instance is in the middle of an exit, don't attempt any kind of state update. if (!this.IsCurrentScreen()) return; - // Special case to handle rewinding post-completion. This is the only way already queued forward progress can be cancelled. - // TODO: Investigate whether this can be moved to a RewindablePlayer subclass or similar. - // Currently, even if this scenario is hit, prepareScoreForDisplay has already been queued (and potentially run). - // In scenarios where rewinding is possible (replay, spectating) this is a non-issue as no submission/import work is done, - // but it still doesn't feel right that this exists here. - if (!completed.NewValue) + // Handle cases of arriving at this method when not in a completed state. + // - When a storyboard completion triggered this call earlier than gameplay finishes. + // - When a replay has been rewound before a queued resultsDisplayDelegate has run. + // + // Currently, even if this scenario is hit, prepareAndImportScoreAsync has already been queued (and potentially run). + // In the scenarios above, this is a non-issue, but it still feels a bit convoluted to have to cancel in this method. + // Maybe this can be improved with further refactoring. + if (!ScoreProcessor.HasCompleted.Value) { resultsDisplayDelegate?.Cancel(); resultsDisplayDelegate = null; @@ -742,12 +739,12 @@ namespace osu.Game.Screens.Play if (!Configuration.ShowResults) return; - bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; + bool storyboardStillRunning = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; - if (storyboardHasOutro) + // If the current beatmap has a storyboard, this method will be called again on storyboard completion. + // Alternatively, the user may press the outro skip button, forcing immediate display of the results screen. + if (storyboardStillRunning) { - // if the current beatmap has a storyboard, the progression to results will be handled by the storyboard ending - // or the user pressing the skip outro button. skipOutroOverlay.Show(); return; } @@ -793,6 +790,8 @@ namespace osu.Game.Screens.Play // This player instance may already be in the process of exiting. return; + Debug.Assert(ScoreProcessor.Rank.Value != ScoreRank.F); + this.Push(CreateResults(prepareScoreForDisplayTask.GetResultSafely())); }, Time.Current + delay, 50); @@ -1112,7 +1111,7 @@ namespace osu.Game.Screens.Play GameplayState.HasQuit = true; // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. - if (prepareScoreForDisplayTask == null) + if (prepareScoreForDisplayTask == null && DrawableRuleset.ReplayScore == null) ScoreProcessor.FailScore(Score.ScoreInfo); } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 4f7e4add32..30ae5ee5aa 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -67,6 +67,8 @@ namespace osu.Game.Screens.Play private OsuScrollContainer settingsScroll = null!; + private Bindable showStoryboards = null!; + private bool backgroundBrightnessReduction; private readonly BindableDouble volumeAdjustment = new BindableDouble(1); @@ -149,10 +151,11 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader] - private void load(SessionStatics sessionStatics, AudioManager audio) + private void load(SessionStatics sessionStatics, AudioManager audio, OsuConfigManager config) { muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); + showStoryboards = config.GetBindable(OsuSetting.ShowStoryboard); const float padding = 25; @@ -463,7 +466,10 @@ namespace osu.Game.Screens.Play // only show if the warning was created (i.e. the beatmap needs it) // and this is not a restart of the map (the warning expires after first load). - if (epilepsyWarning?.IsAlive == true) + // + // note the late check of storyboard enable as the user may have just changed it + // from the settings on the loader screen. + if (epilepsyWarning?.IsAlive == true && showStoryboards.Value) { const double epilepsy_display_length = 3000; @@ -483,6 +489,7 @@ namespace osu.Game.Screens.Play { // This goes hand-in-hand with the restoration of low pass filter in contentOut(). this.TransformBindableTo(volumeAdjustment, 0, CONTENT_OUT_DURATION, Easing.OutCubic); + epilepsyWarning?.Expire(); } pushSequence.Schedule(() => diff --git a/osu.Game/Screens/Play/SquareGraph.cs b/osu.Game/Screens/Play/SquareGraph.cs index 57b7c84e89..b53e86a41b 100644 --- a/osu.Game/Screens/Play/SquareGraph.cs +++ b/osu.Game/Screens/Play/SquareGraph.cs @@ -15,6 +15,7 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Allocation; +using osu.Framework.Layout; using osu.Framework.Threading; namespace osu.Game.Screens.Play @@ -51,7 +52,7 @@ namespace osu.Game.Screens.Play if (value == values) return; values = value; - graphNeedsUpdate = true; + layout.Invalidate(); } } @@ -71,23 +72,25 @@ namespace osu.Game.Screens.Play private ScheduledDelegate scheduledCreate; - private bool graphNeedsUpdate; + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize | Invalidation.DrawInfo); - private Vector2 previousDrawSize; + public SquareGraph() + { + AddLayout(layout); + } protected override void Update() { base.Update(); - if (graphNeedsUpdate || (values != null && DrawSize != previousDrawSize)) + if (!layout.IsValid) { columns?.FadeOut(500, Easing.OutQuint).Expire(); scheduledCreate?.Cancel(); scheduledCreate = Scheduler.AddDelayed(RecreateGraph, 500); - previousDrawSize = DrawSize; - graphNeedsUpdate = false; + layout.Validate(); } } diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 29dec42083..b75f3d86ff 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -9,7 +9,6 @@ using System.Diagnostics; using System.Linq; using System.Threading; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -67,9 +66,6 @@ namespace osu.Game.Screens.Ranking public readonly Bindable SelectedScore = new Bindable(); - [Resolved] - private ScoreManager scoreManager { get; set; } - private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly Flow flow; private readonly Scroll scroll; @@ -149,7 +145,7 @@ namespace osu.Game.Screens.Ranking var score = trackingContainer.Panel.Score; - flow.SetLayoutPosition(trackingContainer, scoreManager.GetTotalScore(score)); + flow.SetLayoutPosition(trackingContainer, score.TotalScore); trackingContainer.Show(); diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs index d10888be43..d68df4558a 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Ranking.Statistics { /// /// Represents a table with simple statistics (ones that only need textual display). - /// Richer visualisations should be done with s and s. + /// Richer visualisations should be done with s. /// public partial class SimpleStatisticTable : CompositeDrawable { diff --git a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs index 57d072b7de..73b9897096 100644 --- a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs @@ -23,32 +23,26 @@ namespace osu.Game.Screens.Ranking.Statistics public Bindable StatisticsUpdate { get; } = new Bindable(); - protected override ICollection CreateStatisticRows(ScoreInfo newScore, IBeatmap playableBeatmap) + protected override ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) { - var rows = base.CreateStatisticRows(newScore, playableBeatmap); + var items = base.CreateStatisticItems(newScore, playableBeatmap); if (newScore.UserID > 1 && newScore.UserID == achievedScore.UserID && newScore.OnlineID > 0 && newScore.OnlineID == achievedScore.OnlineID) { - rows = rows.Append(new StatisticRow + items = items.Append(new StatisticItem("Overall Ranking", () => new OverallRanking { - Columns = new[] - { - new StatisticItem("Overall Ranking", () => new OverallRanking - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - StatisticsUpdate = { BindTarget = StatisticsUpdate } - }) - } - }).ToArray(); + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + StatisticsUpdate = { BindTarget = StatisticsUpdate } + })).ToArray(); } - return rows; + return items; } } } diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs index 5bbd260d3f..c5bdc6f6f5 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs @@ -6,7 +6,6 @@ using System; using JetBrains.Annotations; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; namespace osu.Game.Screens.Ranking.Statistics @@ -26,29 +25,22 @@ namespace osu.Game.Screens.Ranking.Statistics /// public readonly Func CreateContent; - /// - /// The of this row. This can be thought of as the column dimension of an encompassing . - /// - public readonly Dimension Dimension; - /// /// Whether this item requires hit events. If true, will not be called if no hit events are available. /// public readonly bool RequiresHitEvents; /// - /// Creates a new , to be displayed inside a in the results screen. + /// Creates a new , to be displayed in the results screen. /// /// The name of the item. Can be to hide the item header. /// A function returning the content to be displayed. /// Whether this item requires hit events. If true, will not be called if no hit events are available. - /// The of this item. This can be thought of as the column dimension of an encompassing . - public StatisticItem(LocalisableString name, [NotNull] Func createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null) + public StatisticItem(LocalisableString name, [NotNull] Func createContent, bool requiresHitEvents = false) { Name = name; RequiresHitEvents = requiresHitEvents; CreateContent = createContent; - Dimension = dimension; } } } diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs deleted file mode 100644 index 9f5f44918e..0000000000 --- a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using JetBrains.Annotations; - -namespace osu.Game.Screens.Ranking.Statistics -{ - /// - /// A row of statistics to be displayed in the results screen. - /// - public class StatisticRow - { - /// - /// The columns of this . - /// - [ItemNotNull] - public StatisticItem[] Columns; - } -} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index 4c22afd8f7..c36d7726dc 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -100,9 +100,9 @@ namespace osu.Game.Screens.Ranking.Statistics bool hitEventsAvailable = newScore.HitEvents.Count != 0; Container container; - var statisticRows = CreateStatisticRows(newScore, task.GetResultSafely()); + var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely()); - if (!hitEventsAvailable && statisticRows.SelectMany(r => r.Columns).All(c => c.RequiresHitEvents)) + if (!hitEventsAvailable && statisticItems.All(c => c.RequiresHitEvents)) { container = new FillFlowContainer { @@ -144,33 +144,22 @@ namespace osu.Game.Screens.Ranking.Statistics bool anyRequiredHitEvents = false; - foreach (var row in statisticRows) + foreach (var item in statisticItems) { - var columns = row.Columns; - - if (columns.Length == 0) - continue; - var columnContent = new List(); - var dimensions = new List(); - foreach (var col in columns) + if (!hitEventsAvailable && item.RequiresHitEvents) { - if (!hitEventsAvailable && col.RequiresHitEvents) - { - anyRequiredHitEvents = true; - continue; - } - - columnContent.Add(new StatisticContainer(col) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }); - - dimensions.Add(col.Dimension ?? new Dimension()); + anyRequiredHitEvents = true; + continue; } + columnContent.Add(new StatisticContainer(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + rows.Add(new GridContainer { Anchor = Anchor.TopCentre, @@ -178,7 +167,7 @@ namespace osu.Game.Screens.Ranking.Statistics RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Content = new[] { columnContent.ToArray() }, - ColumnDimensions = dimensions.ToArray(), + ColumnDimensions = new[] { new Dimension() }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } }); } @@ -219,11 +208,11 @@ namespace osu.Game.Screens.Ranking.Statistics } /// - /// Creates the s to be displayed in this panel for a given . + /// Creates the s to be displayed in this panel for a given . /// /// The score to create the rows for. /// The beatmap on which the score was set. - protected virtual ICollection CreateStatisticRows(ScoreInfo newScore, IBeatmap playableBeatmap) + protected virtual ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) => newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap); protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 774ecc2c9c..6ba9843f7b 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -45,10 +45,15 @@ namespace osu.Game.Screens.Select public float BleedBottom { get; set; } /// - /// Triggered when the loaded change and are completely loaded. + /// Triggered when finish loading, or are subsequently changed. /// public Action? BeatmapSetsChanged; + /// + /// Triggered after filter conditions have finished being applied to the model hierarchy. + /// + public Action? FilterApplied; + /// /// The currently selected beatmap. /// @@ -56,6 +61,11 @@ namespace osu.Game.Screens.Select private CarouselBeatmap? selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected); + /// + /// The total count of non-filtered beatmaps displayed. + /// + public int CountDisplayed => beatmapSets.Where(s => !s.Filtered.Value).Sum(s => s.Beatmaps.Count(b => !b.Filtered.Value)); + /// /// The currently selected beatmap set. /// @@ -343,6 +353,8 @@ namespace osu.Game.Screens.Select if (!Scroll.UserScrolling) ScrollToSelected(true); + + BeatmapSetsChanged?.Invoke(); }); public void UpdateBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => @@ -639,6 +651,8 @@ namespace osu.Game.Screens.Select if (alwaysResetScrollPosition || !Scroll.UserScrolling) ScrollToSelected(true); + + FilterApplied?.Invoke(); } } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 3975bb6bb6..b97d37c854 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -108,7 +108,8 @@ namespace osu.Game.Screens.Select.Carousel Header.Children = new Drawable[] { - background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault())) + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). + background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) { RelativeSizeAxes = Axes.Both, }, 300) diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs index f065926eb7..0c3de5848b 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs @@ -58,7 +58,7 @@ namespace osu.Game.Screens.Select.Carousel item = value; - if (IsLoaded) + if (IsLoaded && !IsDisposed) UpdateItem(); } } @@ -165,5 +165,13 @@ namespace osu.Game.Screens.Select.Carousel Item.State.Value = CarouselItemState.Selected; return true; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + // This is important to clean up event subscriptions. + Item = null; + } } } diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 2f21ffbe6a..38520a85b7 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Graphics; @@ -27,20 +28,27 @@ namespace osu.Game.Screens.Select { public partial class FilterControl : Container { - public const float HEIGHT = 2 * side_margin + 85; - private const float side_margin = 20; + public const float HEIGHT = 2 * side_margin + 120; + + private const float side_margin = 10; public Action FilterChanged; public Bindable CurrentTextSearch => searchTextBox.Current; + public LocalisableString InformationalText + { + get => searchTextBox.FilterText.Text; + set => searchTextBox.FilterText.Text = value; + } + private OsuTabControl sortTabs; private Bindable sortMode; private Bindable groupMode; - private SeekLimitedSearchTextBox searchTextBox; + private FilterControlTextBox searchTextBox; private CollectionDropdown collectionDropdown; @@ -99,72 +107,63 @@ namespace osu.Game.Screens.Select { RelativeSizeAxes = Axes.Both, Spacing = new Vector2(0, 5), - Children = new[] + Children = new Drawable[] { - new Container + searchTextBox = new FilterControlTextBox { RelativeSizeAxes = Axes.X, - Height = 60, - Children = new Drawable[] + }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = 1, + Colour = OsuColour.Gray(80), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] { - searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X }, - new Box + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), + new Dimension(), + new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] + { + new[] { - RelativeSizeAxes = Axes.X, - Height = 1, - Colour = OsuColour.Gray(80), - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - }, - new GridContainer - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] + new OsuSpriteText { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), - new Dimension(), - new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), - new Dimension(GridSizeMode.AutoSize), + Text = SortStrings.Default, + Font = OsuFont.GetFont(size: 14), + Margin = new MarginPadding(5), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, }, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - Content = new[] + Empty(), + sortTabs = new OsuTabControl { - new[] - { - new OsuSpriteText - { - Text = SortStrings.Default, - Font = OsuFont.GetFont(size: 14), - Margin = new MarginPadding(5), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - Empty(), - sortTabs = new OsuTabControl - { - RelativeSizeAxes = Axes.X, - Height = 24, - AutoSort = true, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AccentColour = colours.GreenLight, - Current = { BindTarget = sortMode } - }, - Empty(), - new OsuTabControlCheckbox - { - Text = "Show converted", - Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - } - } - }, + RelativeSizeAxes = Axes.X, + Height = 24, + AutoSort = true, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AccentColour = colours.GreenLight, + Current = { BindTarget = sortMode } + }, + Empty(), + new OsuTabControlCheckbox + { + Text = "Show converted", + Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } } }, new Container @@ -248,5 +247,33 @@ namespace osu.Game.Screens.Select protected override bool OnClick(ClickEvent e) => true; protected override bool OnHover(HoverEvent e) => true; + + private partial class FilterControlTextBox : SeekLimitedSearchTextBox + { + private const float filter_text_size = 12; + + public OsuSpriteText FilterText { get; private set; } + + public FilterControlTextBox() + { + Height += filter_text_size; + TextContainer.Height *= (Height - filter_text_size) / Height; + TextContainer.Margin = new MarginPadding { Bottom = filter_text_size }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + TextContainer.Add(FilterText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Depth = float.MinValue, + Font = OsuFont.Default.With(size: filter_text_size, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Top = 2, Left = 2 }, + Colour = colours.Yellow + }); + } + } } } diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index f413126e87..2d5d049133 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -93,16 +93,22 @@ namespace osu.Game.Screens.Select protected override bool OnKeyDown(KeyDownEvent e) { - updateText(e.ShiftPressed); + updateText(e); return base.OnKeyDown(e); } protected override void OnKeyUp(KeyUpEvent e) { - updateText(e.ShiftPressed); + updateText(e); base.OnKeyUp(e); } + protected override bool OnMouseDown(MouseDownEvent e) + { + updateText(e); + return base.OnMouseDown(e); + } + protected override bool OnClick(ClickEvent e) { try @@ -119,14 +125,15 @@ namespace osu.Game.Screens.Select protected override void OnMouseUp(MouseUpEvent e) { + base.OnMouseUp(e); + if (e.Button == MouseButton.Right && IsHovered) { rewindSearch = true; TriggerClick(); - return; } - base.OnMouseUp(e); + updateText(e); } public override bool OnPressed(KeyBindingPressEvent e) @@ -151,10 +158,12 @@ namespace osu.Game.Screens.Select } } - private void updateText(bool rewind = false) + private void updateText(UIEvent e) { - randomSpriteText.Alpha = rewind ? 0 : 1; - rewindSpriteText.Alpha = rewind ? 1 : 0; + bool aboutToRewind = e.ShiftPressed || e.CurrentState.Mouse.IsPressed(MouseButton.Right); + + randomSpriteText.Alpha = aboutToRewind ? 0 : 1; + rewindSpriteText.Alpha = aboutToRewind ? 1 : 0; } } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 661eec8e97..4d6a5398c5 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -162,6 +163,7 @@ namespace osu.Game.Screens.Select BleedBottom = Footer.HEIGHT, SelectionChanged = updateSelectedBeatmap, BeatmapSetsChanged = carouselBeatmapsLoaded, + FilterApplied = updateVisibleBeatmapCount, GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s), }, c => carouselContainer.Child = c); @@ -828,6 +830,7 @@ namespace osu.Game.Screens.Select private void carouselBeatmapsLoaded() { bindBindables(); + updateVisibleBeatmapCount(); Carousel.AllowSelection = true; @@ -857,6 +860,13 @@ namespace osu.Game.Screens.Select } } + private void updateVisibleBeatmapCount() + { + // 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.InformationalText = $"{"match".ToQuantity(Carousel.CountDisplayed, "#,0")}"; + } + private bool boundLocalBindables; private void bindBindables() diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs index 2c16a67cac..6523039a3f 100644 --- a/osu.Game/Skinning/Components/BeatmapAttributeText.cs +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -84,8 +84,8 @@ namespace osu.Game.Skinning.Components private void updateBeatmapContent(WorkingBeatmap workingBeatmap) { - valueDictionary[BeatmapAttribute.Title] = workingBeatmap.BeatmapInfo.Metadata.Title; - valueDictionary[BeatmapAttribute.Artist] = workingBeatmap.BeatmapInfo.Metadata.Artist; + valueDictionary[BeatmapAttribute.Title] = new RomanisableString(workingBeatmap.BeatmapInfo.Metadata.TitleUnicode, workingBeatmap.BeatmapInfo.Metadata.Title); + valueDictionary[BeatmapAttribute.Artist] = new RomanisableString(workingBeatmap.BeatmapInfo.Metadata.ArtistUnicode, workingBeatmap.BeatmapInfo.Metadata.Artist); valueDictionary[BeatmapAttribute.DifficultyName] = workingBeatmap.BeatmapInfo.DifficultyName; valueDictionary[BeatmapAttribute.Creator] = workingBeatmap.BeatmapInfo.Metadata.Author.Username; valueDictionary[BeatmapAttribute.Length] = TimeSpan.FromMilliseconds(workingBeatmap.BeatmapInfo.Length).ToFormattedDuration(); diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index 0158c47ea3..76c2c4d7ec 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -9,7 +9,6 @@ using JetBrains.Annotations; 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.Audio; using osu.Framework.Graphics.Containers; @@ -70,20 +69,6 @@ namespace osu.Game.Skinning updateSample(); } - protected override void LoadComplete() - { - base.LoadComplete(); - - CurrentSkin.SourceChanged += skinChangedImmediate; - } - - private void skinChangedImmediate() - { - // Clean up the previous sample immediately on a source change. - // This avoids a potential call to Play() of an already disposed sample (samples are disposed along with the skin, but SkinChanged is scheduled). - clearPreviousSamples(); - } - protected override void SkinChanged(ISkinSource skin) { base.SkinChanged(skin); @@ -109,6 +94,8 @@ namespace osu.Game.Skinning private void updateSample() { + clearPreviousSamples(); + if (sampleInfo == null) return; @@ -129,6 +116,8 @@ namespace osu.Game.Skinning /// public void Play() { + FlushPendingSkinChanges(); + if (Sample == null) return; @@ -172,14 +161,6 @@ namespace osu.Game.Skinning } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (CurrentSkin.IsNotNull()) - CurrentSkin.SourceChanged -= skinChangedImmediate; - } - #region Re-expose AudioContainer public BindableNumber Volume => sampleContainer.Volume; diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 9ad91f8725..c2b80b7ead 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Testing; using osu.Game.Database; using osu.Game.IO; using osu.Game.Models; @@ -13,7 +12,6 @@ using Realms; namespace osu.Game.Skinning { - [ExcludeFromDynamicCompile] [MapTo("Skin")] [JsonObject(MemberSerialization.OptIn)] public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index fca7dc0f5e..51605c6045 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -18,7 +18,6 @@ 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.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio; @@ -36,7 +35,6 @@ namespace osu.Game.Skinning /// This is also exposed and cached as to allow for any component to potentially have skinning support. /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. /// - [ExcludeFromDynamicCompile] public class SkinManager : ModelManager, ISkinSource, IStorageResourceProvider, IModelImporter { /// @@ -58,6 +56,8 @@ namespace osu.Game.Skinning private readonly SkinImporter skinImporter; + private readonly LegacySkinExporter skinExporter; + private readonly IResourceStore userFiles; private Skin argonSkin { get; } @@ -120,6 +120,11 @@ namespace osu.Game.Skinning SourceChanged?.Invoke(); }; + + skinExporter = new LegacySkinExporter(storage) + { + PostNotification = obj => PostNotification?.Invoke(obj) + }; } public void SelectRandomSkin() @@ -298,6 +303,10 @@ namespace osu.Game.Skinning public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => skinImporter.Import(task, parameters, cancellationToken); + public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value); + + public Task ExportSkin(Live skin) => skinExporter.ExportAsync(skin); + #endregion public void Delete([CanBeNull] Expression> filter = null, bool silent = false) diff --git a/osu.Game/Skinning/SkinReloadableDrawable.cs b/osu.Game/Skinning/SkinReloadableDrawable.cs index cef1db4bc0..c7b33dc539 100644 --- a/osu.Game/Skinning/SkinReloadableDrawable.cs +++ b/osu.Game/Skinning/SkinReloadableDrawable.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Pooling; +using osu.Framework.Threading; namespace osu.Game.Skinning { @@ -14,6 +15,8 @@ namespace osu.Game.Skinning /// public abstract partial class SkinReloadableDrawable : PoolableDrawable { + private ScheduledDelegate? pendingSkinChange; + /// /// Invoked when has changed. /// @@ -31,21 +34,30 @@ namespace osu.Game.Skinning CurrentSkin.SourceChanged += onChange; } - private void onChange() => - // schedule required to avoid calls after disposed. - // note that this has the side-effect of components only performing a skin change when they are alive. - Scheduler.AddOnce(skinChanged); - protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); skinChanged(); } - private void skinChanged() + /// + /// Force any pending calls to be performed immediately. + /// + /// + /// When a skin change occurs, the handling provided by this class is scheduled. + /// In some cases, such a sample playback, this can result in the sample being played + /// just before it is updated to a potentially different sample. + /// + /// Calling this method will ensure any pending update operations are run immediately. + /// It is recommended to call this before consuming the result of skin changes for anything non-drawable. + /// + protected void FlushPendingSkinChanges() { - SkinChanged(CurrentSkin); - OnSkinChanged?.Invoke(); + if (pendingSkinChange == null) + return; + + pendingSkinChange.RunTask(); + pendingSkinChange = null; } /// @@ -56,6 +68,22 @@ namespace osu.Game.Skinning { } + private void onChange() + { + // schedule required to avoid calls after disposed. + // note that this has the side-effect of components only performing a skin change when they are alive. + pendingSkinChange?.Cancel(); + pendingSkinChange = Scheduler.Add(skinChanged); + } + + private void skinChanged() + { + SkinChanged(CurrentSkin); + OnSkinChanged?.Invoke(); + + pendingSkinChange = null; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index 475b79053a..59b3799e0a 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -115,6 +115,8 @@ namespace osu.Game.Skinning /// public virtual void Play() { + FlushPendingSkinChanges(); + samplesContainer.ForEach(c => { if (PlayWhenZeroVolume || c.AggregateVolume.Value > 0) diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index aa264fa719..e674e7512c 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -69,7 +69,7 @@ namespace osu.Game.Storyboards.Drawables Size = new Vector2(640, 480); - bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).Any(e => !(e is StoryboardVideo)); + bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).All(e => e is StoryboardVideo); Width = Height * (storyboard.BeatmapInfo.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index e598c79b08..be77c9a98e 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -85,7 +85,7 @@ namespace osu.Game.Storyboards.Drawables Loop = animation.LoopType == AnimationLoopType.LoopForever; LifetimeStart = animation.StartTime; - LifetimeEnd = animation.EndTime; + LifetimeEnd = animation.EndTimeForDisplay; } [Resolved] diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index f9b09ed57c..400d33481c 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -82,7 +82,7 @@ namespace osu.Game.Storyboards.Drawables Position = sprite.InitialPosition; LifetimeStart = sprite.StartTime; - LifetimeEnd = sprite.EndTime; + LifetimeEnd = sprite.EndTimeForDisplay; } [Resolved] diff --git a/osu.Game/Storyboards/IStoryboardElementWithDuration.cs b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs index c8daeb3b3d..9eed139ad4 100644 --- a/osu.Game/Storyboards/IStoryboardElementWithDuration.cs +++ b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs @@ -12,9 +12,17 @@ namespace osu.Game.Storyboards { /// /// The time at which the ends. + /// This is consumed to extend the length of a storyboard to ensure all visuals are played to completion. /// double EndTime { get; } + /// + /// The time this element displays until. + /// This is used for lifetime purposes, and includes long playing animations which don't necessarily extend + /// a storyboard's play time. + /// + double EndTimeForDisplay { get; } + /// /// The duration of the StoryboardElement. /// diff --git a/osu.Game/Storyboards/StoryboardAnimation.cs b/osu.Game/Storyboards/StoryboardAnimation.cs index 16deac8e9e..1a4b6bb923 100644 --- a/osu.Game/Storyboards/StoryboardAnimation.cs +++ b/osu.Game/Storyboards/StoryboardAnimation.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 osuTK; using osu.Framework.Graphics; using osu.Game.Storyboards.Drawables; diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index 5b7b194be7..982185d51b 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.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.Graphics; using osu.Game.Storyboards.Drawables; using osuTK; @@ -84,6 +81,19 @@ namespace osu.Game.Storyboards } } + public double EndTimeForDisplay + { + get + { + double latestEndTime = TimelineGroup.EndTime; + + foreach (var l in loops) + latestEndTime = Math.Max(latestEndTime, l.StartTime + l.CommandsDuration * l.TotalIterations); + + return latestEndTime; + } + } + public bool HasCommands => TimelineGroup.HasCommands || loops.Any(l => l.HasCommands); private delegate void DrawablePropertyInitializer(Drawable drawable, T value); @@ -114,7 +124,7 @@ namespace osu.Game.Storyboards public virtual Drawable CreateDrawable() => new DrawableStoryboardSprite(this); - public void ApplyTransforms(Drawable drawable, IEnumerable> triggeredGroups = null) + public void ApplyTransforms(Drawable drawable, IEnumerable>? triggeredGroups = null) { // For performance reasons, we need to apply the commands in order by start time. Not doing so will cause many functions to be interleaved, resulting in O(n^2) complexity. // To achieve this, commands are "generated" as pairs of (command, initFunc, transformFunc) and batched into a contiguous list @@ -156,7 +166,7 @@ namespace osu.Game.Storyboards foreach (var command in commands) { - DrawablePropertyInitializer initFunc = null; + DrawablePropertyInitializer? initFunc = null; if (!initialized) { @@ -169,7 +179,7 @@ namespace osu.Game.Storyboards } } - private IEnumerable.TypedCommand> getCommands(CommandTimelineSelector timelineSelector, IEnumerable> triggeredGroups) + private IEnumerable.TypedCommand> getCommands(CommandTimelineSelector timelineSelector, IEnumerable>? triggeredGroups) { var commands = TimelineGroup.GetCommands(timelineSelector); foreach (var loop in loops) @@ -198,11 +208,11 @@ namespace osu.Game.Storyboards { public double StartTime => command.StartTime; - private readonly DrawablePropertyInitializer initializeProperty; + private readonly DrawablePropertyInitializer? initializeProperty; private readonly DrawableTransformer transform; private readonly CommandTimeline.TypedCommand command; - public GeneratedCommand([NotNull] CommandTimeline.TypedCommand command, [CanBeNull] DrawablePropertyInitializer initializeProperty, [NotNull] DrawableTransformer transform) + public GeneratedCommand(CommandTimeline.TypedCommand command, DrawablePropertyInitializer? initializeProperty, DrawableTransformer transform) { this.command = command; this.initializeProperty = initializeProperty; diff --git a/osu.Game/Tests/Visual/EditorSavingTestScene.cs b/osu.Game/Tests/Visual/EditorSavingTestScene.cs index cd9e9e1d52..78188d7cf7 100644 --- a/osu.Game/Tests/Visual/EditorSavingTestScene.cs +++ b/osu.Game/Tests/Visual/EditorSavingTestScene.cs @@ -3,9 +3,12 @@ #nullable disable +using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Input; using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; @@ -24,18 +27,27 @@ namespace osu.Game.Tests.Visual protected EditorBeatmap EditorBeatmap => (EditorBeatmap)Editor.Dependencies.Get(typeof(EditorBeatmap)); + [CanBeNull] + protected Func CreateInitialBeatmap { get; set; } + [SetUpSteps] public override void SetUpSteps() { base.SetUpSteps(); - AddStep("set default beatmap", () => Game.Beatmap.SetDefault()); + if (CreateInitialBeatmap == null) + AddStep("set default beatmap", () => Game.Beatmap.SetDefault()); + else + { + AddStep("set test beatmap", () => Game.Beatmap.Value = CreateInitialBeatmap?.Invoke()); + } PushAndConfirm(() => new EditorLoader()); AddUntilStep("wait for editor load", () => Editor?.IsLoaded == true); - AddUntilStep("wait for metadata screen load", () => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + if (CreateInitialBeatmap == null) + AddUntilStep("wait for metadata screen load", () => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); // We intentionally switch away from the metadata screen, else there is a feedback loop with the textbox handling which causes metadata changes below to get overwritten. @@ -50,6 +62,14 @@ namespace osu.Game.Tests.Visual protected void ReloadEditorToSameBeatmap() { + Guid beatmapSetGuid = Guid.Empty; + Guid beatmapGuid = Guid.Empty; + + AddStep("Store beatmap GUIDs", () => + { + beatmapSetGuid = EditorBeatmap.BeatmapInfo.BeatmapSet!.ID; + beatmapGuid = EditorBeatmap.BeatmapInfo.ID; + }); AddStep("Exit", () => InputManager.Key(Key.Escape)); AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -59,7 +79,8 @@ namespace osu.Game.Tests.Visual PushAndConfirm(() => songSelect = new PlaySongSelect()); AddUntilStep("wait for carousel load", () => songSelect.BeatmapSetsLoaded); - AddUntilStep("Wait for beatmap selected", () => !Game.Beatmap.IsDefault); + 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)); diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index ad5e3f6c4d..c27e30d5bb 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -108,7 +108,8 @@ namespace osu.Game.Tests.Visual.Multiplayer // simulate the server's automatic assignment of users to teams on join. // the "best" team is the one with the least users on it. int bestTeam = teamVersus.Teams - .Select(team => (teamID: team.ID, userCount: ServerRoom.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID))).MinBy(pair => pair.userCount).teamID; + .Select(team => (teamID: team.ID, userCount: ServerRoom.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID))) + .MinBy(pair => pair.userCount).teamID; user.MatchState = new TeamVersusUserState { TeamID = bestTeam }; ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).WaitSafely(); @@ -232,7 +233,7 @@ namespace osu.Game.Tests.Visual.Multiplayer QueueMode = ServerAPIRoom.QueueMode.Value, AutoStartDuration = ServerAPIRoom.AutoStartDuration.Value }, - Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), + Playlist = ServerAPIRoom.Playlist.Select(CreateMultiplayerPlaylistItem).ToList(), Users = { localUser }, Host = localUser }; @@ -637,5 +638,20 @@ namespace osu.Game.Tests.Visual.Multiplayer byte[]? serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS); 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, + }; } } diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 46c7c3a57c..0ec5a4c5c2 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -36,7 +36,6 @@ using osu.Game.Tests.Rulesets; namespace osu.Game.Tests.Visual { - [ExcludeFromDynamicCompile] public abstract partial class OsuTestScene : TestScene { [Cached] diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index 1db35b3aaa..305a615102 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -12,7 +12,9 @@ using osu.Framework.Utils; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; +using osu.Game.Rulesets; using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Tests.Visual.Spectator @@ -44,6 +46,9 @@ namespace osu.Game.Tests.Visual.Spectator [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + public TestSpectatorClient() { OnNewFrames += (i, bundle) => lastReceivedUserFrames[i] = bundle.Frames[^1]; @@ -119,7 +124,7 @@ namespace osu.Game.Tests.Visual.Spectator if (frames.Count == 0) return; - var bundle = new FrameDataBundle(new ScoreInfo { Combo = currentFrameIndex }, frames.ToArray()); + var bundle = new FrameDataBundle(new ScoreInfo { Combo = currentFrameIndex }, new ScoreProcessor(rulesetStore.GetRuleset(0)!.CreateInstance()), frames.ToArray()); ((ISpectatorClient)this).UserSentFrames(userId, bundle); frames.Clear(); diff --git a/osu.Game/Users/TournamentBanner.cs b/osu.Game/Users/TournamentBanner.cs index 62e1913412..e7fada1eff 100644 --- a/osu.Game/Users/TournamentBanner.cs +++ b/osu.Game/Users/TournamentBanner.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.IO; using Newtonsoft.Json; namespace osu.Game.Users @@ -17,7 +16,7 @@ namespace osu.Game.Users [JsonProperty("image")] public string ImageLowRes = null!; - // TODO: remove when api returns @2x image link: https://github.com/ppy/osu-web/issues/9816 - public string Image => $@"{Path.ChangeExtension(ImageLowRes, null)}@2x{Path.GetExtension(ImageLowRes)}"; + [JsonProperty("image@2x")] + public string Image = null!; } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index c08dc9ed8f..8a941ca6c1 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -30,14 +30,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index 8738979c57..1dcece7741 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -16,6 +16,6 @@ iossimulator-x64 - + diff --git a/osu.iOS/AppDelegate.cs b/osu.iOS/AppDelegate.cs deleted file mode 100644 index 1d29d59fff..0000000000 --- a/osu.iOS/AppDelegate.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. - -#nullable disable - -using System.Threading.Tasks; -using Foundation; -using osu.Framework.iOS; -using UIKit; - -namespace osu.iOS -{ - [Register("AppDelegate")] - public class AppDelegate : GameAppDelegate - { - private OsuGameIOS game; - - protected override Framework.Game CreateGame() => game = new OsuGameIOS(); - - public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) - { - if (url.IsFileUrl) - Task.Run(() => game.Import(url.Path)); - else - Task.Run(() => game.HandleLink(url.AbsoluteString)); - return true; - } - } -} diff --git a/osu.iOS/Application.cs b/osu.iOS/Application.cs index 64eb5c63f5..74bd58acb8 100644 --- a/osu.iOS/Application.cs +++ b/osu.iOS/Application.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. -#nullable disable - -using UIKit; +using osu.Framework.iOS; namespace osu.iOS { @@ -11,7 +9,7 @@ namespace osu.iOS { public static void Main(string[] args) { - UIApplication.Main(args, null, typeof(AppDelegate)); + GameApplication.Main(new OsuGameIOS()); } } } diff --git a/osu.iOS/IOSMouseSettings.cs b/osu.iOS/IOSMouseSettings.cs deleted file mode 100644 index f464bd93b8..0000000000 --- a/osu.iOS/IOSMouseSettings.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Localisation; -using osu.Game.Configuration; -using osu.Game.Localisation; -using osu.Game.Overlays.Settings; - -namespace osu.iOS -{ - public partial class IOSMouseSettings : SettingsSubsection - { - protected override LocalisableString Header => MouseSettingsStrings.Mouse; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager osuConfig) - { - Children = new Drawable[] - { - new SettingsCheckbox - { - LabelText = MouseSettingsStrings.DisableMouseWheelVolumeAdjust, - TooltipText = MouseSettingsStrings.DisableMouseWheelVolumeAdjustTooltip, - Current = osuConfig.GetBindable(OsuSetting.MouseDisableWheel), - }, - new SettingsCheckbox - { - LabelText = MouseSettingsStrings.DisableMouseButtons, - Current = osuConfig.GetBindable(OsuSetting.MouseDisableButtons), - }, - }; - } - } -} diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 3e79bc6ad6..c49e6907ff 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -7,10 +7,7 @@ using System; using Foundation; using Microsoft.Maui.Devices; using osu.Framework.Graphics; -using osu.Framework.Input.Handlers; -using osu.Framework.iOS.Input; using osu.Game; -using osu.Game.Overlays.Settings; using osu.Game.Updater; using osu.Game.Utils; @@ -29,18 +26,6 @@ namespace osu.iOS // Because we have the home indicator (mostly) hidden we don't really care about drawing in this region. Edges.Bottom; - public override SettingsSubsection CreateSettingsSubsectionFor(InputHandler handler) - { - switch (handler) - { - case IOSMouseHandler: - return new IOSMouseSettings(); - - default: - return base.CreateSettingsSubsectionFor(handler); - } - } - private class IOSBatteryInfo : BatteryInfo { public override double? ChargeLevel => Battery.ChargeLevel; diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 367dfccb71..b54794cd6d 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -277,6 +277,7 @@ Explicit ExpressionBody BlockBody + BlockScoped ExplicitlyTyped True NEXT_LINE @@ -328,6 +329,7 @@ False AABB API + ARGB BPM EF FPS @@ -335,6 +337,7 @@ GL GLSL HID + HSL HSPA HSV HTML @@ -351,6 +354,7 @@ OS PM RGB + RGBA RNG SDL SHA